PostgreSQL VACUUM、Autovacuum、Freeze、Wraparound 与 Bloat
从 Dead Tuple、Page Pruning、Autovacuum 触发阈值、Freeze、Wraparound、表与索引膨胀到生产 Runbook,系统理解 PostgreSQL 长期维护机制。
第 12 章:VACUUM、Autovacuum、Freeze、Wraparound 与 Bloat
技术基线:PostgreSQL 18;兼顾 PostgreSQL 14—17。示例默认在隔离测试库执行。除非明确标为“示例值”,参数值都只是在说明机制,不构成通用生产建议。
1. 本章定位
PostgreSQL 的 UPDATE 和 DELETE 通常不会立刻在原地抹掉旧行版本:UPDATE 创建新 Tuple,旧 Tuple 进入不可见但尚待回收的状态;DELETE 先标记删除,之后也要等待安全清理。这个设计是 MVCC 高并发读取的基础,但同时产生四类长期成本:
- Heap 和索引里积累不可复用或尚未清理的版本,形成读放大、写放大和空间放大;
- Visibility Map 不能及时恢复,Index Only Scan 退化为频繁 Heap Fetch;
- Planner 统计信息陈旧,执行计划偏离真实数据分布;
- 事务 ID 与 MultiXact ID 持续消耗,极端情况下触发反环绕保护并停止新的事务 ID 分配。
本章解决的是“数据库运行几个月或几年后,为什么没有明显业务增长却越来越慢、越来越大,以及怎样在不中断服务的前提下控制这种退化”。它依赖第 3 章的 Page/Tuple/HOT、第 9 章的 MVCC/Snapshot 和第 11 章的锁;下一章将继续解释 VACUUM 产生的 WAL、Checkpoint 与复制重放成本。
本章不展开 B-tree 分裂算法、WAL Record 二进制格式、备份恢复流程和完整复制协议;只说明它们与 VACUUM 的边界。
2. 可验证的学习目标
完成本章后,你应能够:
- 根据 Tuple 的
xmin/xmax、快照边界和backend_xmin,解释 Dead 与 Recently Dead 的差异; - 画出 Page Pruning、HOT Pruning、Lazy VACUUM、索引清理、FSM/VM 更新和尾部截断的完整路径;
- 用表规模和变更速率计算 Autovacuum 触发阈值,并识别默认 scale factor 对超大表的失效场景;
- 用
pg_stat_all_tables、pg_stat_progress_vacuum、pg_stat_activity、pg_prepared_xacts和pg_replication_slots定位清理地平线; - 准确区分 replication slot 的 WAL 保留 (
restart_lsn) 与 Tuple 回收地平线 (xmin/catalog_xmin); - 复现长事务让 VACUUM 无法删除旧版本的现象;
- 判断应使用普通
VACUUM、VACUUM FULL、REINDEX [CONCURRENTLY]还是pg_repack; - 分析一次维护对 CPU、内存、shared buffers、OS Page Cache、I/O、WAL、Checkpoint、主从延迟和 P99 的影响;
- 实现一个基于 pgxpool 的有界并发健康检查器,识别长事务、idle in transaction、维护债务和环绕风险;
- 在出现反环绕告警、表膨胀或副本冲突时执行可审计的生产 Runbook,并验证修复结果。
3. 核心术语
| 中文名称 | English | 准确定义 | 容易混淆的概念 | 层次 |
|---|---|---|---|---|
| 死行版本 | Dead Tuple | 已不可能被任何相关快照看到、可由 VACUUM 回收的 Heap Tuple | “被 DELETE”不等于立即 Dead | Tuple/MVCC |
| 近期死亡版本 | Recently Dead | 对当前判断已删除,但仍可能被某个较老快照看到,暂不可移除 | Dead Tuple | Tuple/MVCC |
| 页面剪枝 | Page Pruning | 在单个 Heap Page 内清理可移除版本、重定向 HOT 链并压实可用空间 | 完整 VACUUM;它不清理索引 | Heap Page |
| HOT 剪枝 | HOT Pruning | 对 HOT 链中的中间版本进行页面内回收和 line pointer 重定向 | HOT Update | Heap Page |
| Lazy VACUUM | Lazy VACUUM | 普通、非 FULL 的 VACUUM 算法族:低强度锁下扫描 Heap、清索引、回收 Heap、更新 VM/FSM | VACUUM FULL | 维护流程 |
| 全量真空 | VACUUM FULL | 重写表到新物理文件并重建索引,以紧凑布局返还空间 | 普通 VACUUM | 表重写/锁 |
| 自动清理 | Autovacuum | Launcher 调度 Worker,按统计阈值执行 VACUUM/ANALYZE,并承担反环绕清理 | “后台一直在跑”不代表赶得上 | 后台进程 |
| 冻结 | Freeze | 将足够老且已提交的插入事务视为永久可见,解除其 XID 环绕风险 | 物理删除 | Tuple/XID |
| 事务 ID 环绕 | XID Wraparound | 32 位 XID 循环使用后,新旧顺序可能反转;必须通过冻结维持可见性语义 | XID 耗尽只是表象 | 集群安全 |
| 反环绕 VACUUM | Anti-wraparound Vacuum | 由 XID/MultiXact 年龄触发的强制维护,优先保证可用性安全而非平滑负载 | 普通阈值 VACUUM | 后台维护 |
| MultiXact | MultiXact ID | 表示多个事务共同持有 Tuple 锁的 ID;也会环绕并需冻结/截断成员存储 | 普通 XID | Tuple Lock |
| 表冻结下界 | relfrozenxid | 某 Relation 中所有更老 XID 均已冻结的保守下界 | “表中最老 xmin”的精确值 | 系统目录 |
| 数据库冻结下界 | datfrozenxid | 数据库各需维护 Relation 的冻结下界汇总 | 集群级绝对下界 | 系统目录 |
| 自由空间映射 | FSM | 记录 Heap/Index Page 可用空间,帮助插入或更新寻找可容纳页面 | VM | 辅助 Fork |
| 可见性映射 | Visibility Map | 每个 Heap Page 两个 bit:all-visible 与 all-frozen | FSM;索引自身没有 VM | 辅助 Fork |
| 索引清理 | Index Cleanup | 删除或标记可复用的死 Index Tuple/页面项,解除 Heap TID 引用 | 重建索引 | Index |
| 尾部截断 | Truncation | 普通 VACUUM 在表末尾连续空页满足条件时缩短 Relation 文件 | 全表压缩 | Storage/Lock |
| 表膨胀 | Table Bloat | Heap 文件中长期不能有效复用的死版本、碎片和空页导致的额外空间/扫描成本 | 所有 free space 都是浪费 | Heap |
| 索引膨胀 | Index Bloat | 死索引项、页分裂、低密度页等造成的额外索引层级与 I/O | 表膨胀 | Index |
| 维护内存 | maintenance_work_mem | 手工 VACUUM 等维护操作可使用的内存上限之一 | work_mem | Backend Memory |
| 自动维护内存 | autovacuum_work_mem | 每个 Autovacuum Worker 的维护内存上限;-1 表示继承 maintenance_work_mem | 全局总内存 | Worker Memory |
| 成本延迟 | Vacuum Cost Delay | 用命中、读取、弄脏页面的成本积分与周期睡眠对 VACUUM 节流 | I/O 限速器的精确带宽 | 调度/负载 |
| 估算死行数 | n_dead_tup | 统计子系统维护的估算值,不是逐行精确计数 | pgstattuple 的扫描结果 | 统计信息 |
| WAL 保留点 | restart_lsn | Slot 仍可能需要的最早 WAL 位置 | Tuple 回收地平线 | Replication Slot |
| Tuple 回收地平线 | xmin/catalog_xmin | Slot 声明仍可能需要的普通表/系统目录行版本下界 | restart_lsn | Replication Slot |
4. 整体心智模型

4.1 数据流
DML 把新版本写入 Heap,并按是否 HOT 更新决定是否新增索引项。旧版本不立即删除;当所有可能看到它的快照都越过其删除事务后,它才从 Recently Dead 转为可安全移除。普通 VACUUM 将 Heap 中可删除 Tuple 的 TID 批量交给各索引清理,再回到 Heap 释放 line pointer/空间,更新 FSM 和 VM。
4.2 控制流
Autovacuum Launcher 周期性查看数据库与统计信息,启动 Worker;Worker 根据更新/删除阈值、插入阈值、ANALYZE 阈值以及 XID/MultiXact 年龄选择表。手工 VACUUM 与 Worker 共用主要算法,但使用不同的内存、成本延迟和权限入口。反环绕任务优先级更高,接近 failsafe 时会牺牲非必要索引清理和成本节流来尽快推进冻结边界。
4.3 状态变化
LIVE
├─ UPDATE/DELETE 提交,但旧快照仍存在 ─> RECENTLY_DEAD
│ └─ 旧地平线消失 ─> DEAD
└─ 老且已提交、VACUUM Freeze ─────────> FROZEN(逻辑上永久可见)
Heap Page: 有死版本 -> prune/vacuum -> 可复用空间 -> FSM 更新
VM bit: DML 清除 -> vacuum 验证整页可见/冻结 -> 重新设置
Catalog: vacuum 推进 relfrozenxid/relminmxid -> database 汇总 datfrozenxid/datminmxid
现代 PostgreSQL 的“冻结”主要是设置 Tuple Header 的冻结语义标志;不要把它简单理解成每次都把物理 xmin 字段改写为某个特殊数字。
4.4 故障路径
- 长事务、
idle in transaction、未决 Prepared Transaction 或 Slot 的xmin让全局可见性地平线无法前进;VACUUM 能扫描,却不能移除相关旧版本。 - Autovacuum Worker 数量、I/O 或成本预算不足,新增垃圾速度持续高于清理速度,维护债务单调增长。
hot_standby_feedback=on把副本长查询的 horizon 反馈到主库,减少副本取消,但可能让主库保留大量旧版本。VACUUM FULL、表重写或索引重建在磁盘、锁、WAL、归档或副本重放容量不足时放大故障。- XID/MultiXact 年龄持续上升时,PostgreSQL 会先告警、再进入强制维护,最终为防止数据可见性错误而拒绝分配新 XID。
5. 使用方式
5.1 先确认版本、配置与观测前提
SELECT version();
SHOW autovacuum;
SHOW track_counts;
SHOW autovacuum_max_workers;
SHOW autovacuum_worker_slots; -- [PG18]
SHOW autovacuum_naptime;
SHOW autovacuum_vacuum_threshold;
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_vacuum_max_threshold; -- [PG18]
SHOW autovacuum_vacuum_insert_threshold;
SHOW autovacuum_vacuum_insert_scale_factor;
SHOW autovacuum_freeze_max_age;
SHOW autovacuum_multixact_freeze_max_age;
SHOW autovacuum_vacuum_cost_delay;
SHOW autovacuum_vacuum_cost_limit;
SHOW maintenance_work_mem;
SHOW autovacuum_work_mem;
SHOW vacuum_buffer_usage_limit; -- [PG16+]
SHOW vacuum_truncate; -- [PG18]
SHOW hot_standby_feedback;
track_counts 必须开启,Autovacuum 才能依赖正常统计阈值工作。不要把一次 SHOW 的默认值当成调优结论;先记录数据规模、行宽、每秒变更行数、读写比例、峰值并发、内存、CPU、存储时延/IOPS、归档吞吐和查询 SLO。
5.2 普通 VACUUM、ANALYZE 与 FULL
-- 日常维护:允许并发读写;不能放在显式事务块中执行。
VACUUM (VERBOSE, ANALYZE) app.orders;
-- 紧急情况下可先跳过索引清理以推进 Heap/Freeze;后续必须补做索引治理。
VACUUM (VERBOSE, INDEX_CLEANUP OFF) app.orders;
-- [PG16+] 限定 Buffer Access Strategy;示例值不是建议值。
VACUUM (ANALYZE, BUFFER_USAGE_LIMIT '256MB') app.orders;
-- 仅在已评估锁、临时磁盘、WAL、复制和回滚方案后执行。
VACUUM (FULL, VERBOSE, ANALYZE) app.orders;
-- ANALYZE 可独立运行,触发条件与 VACUUM 不同。
ANALYZE (VERBOSE) app.orders;
必须记住:
- 普通
VACUUM主要让空间在 Relation 内部复用,通常不会把空间返还操作系统;只有表尾连续空页可截断时,文件才可能缩短。 VACUUM FULL重写整张表并取得ACCESS EXCLUSIVE,会阻塞普通读写,且需要额外磁盘;它不是“更彻底的日常 VACUUM”。VACUUM不能在事务块中执行。运行权限通常需要表所有者、数据库所有者、超级用户或具有相应MAINTAIN权限的角色。INDEX_CLEANUP OFF是权衡工具,不是长期设置;它会留下可继续膨胀的死索引项。TRUNCATE OFF可避免普通 VACUUM 为尾部截断尝试较强锁,但会放弃当次文件缩短机会。
5.3 Autovacuum 触发公式
以 PG18 为基线,更新/删除型触发阈值近似为:
uncapped_trigger = autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor × reltuples
vacuum_trigger =
if autovacuum_vacuum_max_threshold < 0:
uncapped_trigger
else:
min(autovacuum_vacuum_max_threshold, uncapped_trigger)
其中 autovacuum_vacuum_max_threshold 是 [PG18] 新增的上限;配置为负值时不限制。插入型触发阈值考虑尚未 all-frozen 的页面比例:
insert_trigger = autovacuum_vacuum_insert_threshold
+ autovacuum_vacuum_insert_scale_factor × reltuples
× (1 - relallfrozen / relpages)
ANALYZE 的近似阈值为:
analyze_trigger = autovacuum_analyze_threshold
+ autovacuum_analyze_scale_factor × reltuples
这些公式使用统计估算,因此触发不是精确到某一行或某一毫秒。Launcher/Worker 还受轮询周期、Worker 槽位、当前工作队列、锁、成本延迟和主机资源约束。
一个十亿行表若沿用 scale_factor=0.2,仅比例项就可能要求约两亿次更新/删除后才触发;这通常远大于可接受维护窗口。因此大表应按“每秒产生多少不可见版本 × 最大允许垃圾存活时间”反推阈值,而不是照抄固定比例。
5.4 每表参数
-- 仅为语法示例。真实值必须由工作负载、SLO 与容量实验得出。
ALTER TABLE app.orders SET (
autovacuum_enabled = true,
autovacuum_vacuum_threshold = 20000,
autovacuum_vacuum_scale_factor = 0.005,
autovacuum_vacuum_insert_threshold = 50000,
autovacuum_vacuum_insert_scale_factor = 0.01,
autovacuum_analyze_threshold = 10000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_vacuum_cost_delay = 2,
autovacuum_vacuum_cost_limit = 1000,
autovacuum_freeze_min_age = 50000000,
vacuum_index_cleanup = 'auto',
vacuum_truncate = true
);
-- 恢复继承系统级配置。
ALTER TABLE app.orders RESET (
autovacuum_vacuum_threshold,
autovacuum_vacuum_scale_factor,
autovacuum_vacuum_insert_threshold,
autovacuum_vacuum_insert_scale_factor,
autovacuum_analyze_threshold,
autovacuum_analyze_scale_factor,
autovacuum_vacuum_cost_delay,
autovacuum_vacuum_cost_limit,
autovacuum_freeze_min_age,
vacuum_index_cleanup,
vacuum_truncate
);
绝不要因为某张表产生延迟尖峰就随意关闭全局 Autovacuum。即使因实验暂时对单表设置 autovacuum_enabled=false,也必须有显式维护和环绕监控;PostgreSQL 仍可能为反环绕安全强制处理该表,这不是可依赖的日常策略。
5.5 估算实际触发阈值
以下查询兼顾全局设置和常用每表 override,并强调 n_dead_tup 只是估算:
WITH g AS (
SELECT
current_setting('autovacuum_vacuum_threshold')::numeric AS base_threshold,
current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale_factor,
COALESCE(
current_setting('autovacuum_vacuum_max_threshold', true), '-1'
)::numeric AS max_threshold
), rel AS (
SELECT
c.oid,
n.nspname,
c.relname,
GREATEST(c.reltuples, 0)::numeric AS reltuples,
s.n_live_tup,
s.n_dead_tup,
s.last_vacuum,
s.last_autovacuum,
COALESCE((
SELECT option_value::numeric
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_vacuum_threshold'
), g.base_threshold) AS base_threshold,
COALESCE((
SELECT option_value::numeric
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_vacuum_scale_factor'
), g.scale_factor) AS scale_factor,
COALESCE((
SELECT option_value::numeric
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_vacuum_max_threshold'
), g.max_threshold) AS max_threshold
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
JOIN pg_stat_all_tables AS s ON s.relid = c.oid
CROSS JOIN g
WHERE c.relkind IN ('r', 'm')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
), calc AS (
SELECT *,
base_threshold + scale_factor * reltuples AS uncapped_trigger
FROM rel
)
SELECT
format('%I.%I', nspname, relname) AS relation,
reltuples::bigint,
n_live_tup,
n_dead_tup,
CASE WHEN max_threshold < 0
THEN uncapped_trigger
ELSE LEAST(max_threshold, uncapped_trigger)
END::bigint AS estimated_vacuum_trigger,
last_vacuum,
last_autovacuum
FROM calc
ORDER BY n_dead_tup DESC;
该查询仍不等价于 Autovacuum 内部调度器:统计快照、Worker 竞争、插入触发、冻结年龄、分区/TOAST 关系和并发变化都会影响实际选择。
5.6 日常诊断 SQL
表级维护债务:
SELECT
relid::regclass AS relation,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct_est,
n_tup_upd,
n_tup_hot_upd,
n_mod_since_analyze,
n_ins_since_vacuum,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count,
last_analyze,
last_autoanalyze,
total_vacuum_time, -- [PG18]
total_autovacuum_time -- [PG18]
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;
n_live_tup、n_dead_tup 都是估算;统计可能刚被 reset,也可能尚未反映最新事务。趋势、变化速率和实际扫描结果比单次百分比更重要。
正在运行的 VACUUM:
SELECT
p.pid,
p.relid::regclass AS relation,
p.phase,
p.heap_blks_total,
p.heap_blks_scanned,
p.heap_blks_vacuumed,
p.index_vacuum_count,
p.max_dead_tuple_bytes, -- [PG17+]
p.dead_tuple_bytes, -- [PG17+]
p.num_dead_item_ids, -- [PG17+]
p.indexes_total,
p.indexes_processed,
p.delay_time -- [PG18,且需 track_cost_delay_timing]
FROM pg_stat_progress_vacuum AS p;
普通 VACUUM 的典型 phase 包括 initializing、scanning heap、vacuuming indexes、vacuuming heap、cleaning up indexes、truncating heap。VACUUM FULL 属于表重写,应查看 pg_stat_progress_cluster,而不是这个视图。
长事务与 idle in transaction:
SELECT
pid, datname, usename, application_name, client_addr,
state, xact_start, query_start,
clock_timestamp() - xact_start AS xact_age,
backend_xmin,
age(backend_xmin) AS xmin_age,
wait_event_type, wait_event,
left(query, 240) AS query_sample
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
AND xact_start IS NOT NULL
ORDER BY xact_start;
xact_start 很老但查询很短,常见原因是应用开启事务后在网络、日志、RPC 或人工交互上停留。state='idle in transaction' 表示当前没有执行 SQL,却仍保留事务上下文;在 Repeatable Read/Serializable 或已取得快照的场景中,它尤其可能固定清理地平线。
Prepared Transaction:
SELECT
transaction, gid, prepared,
clock_timestamp() - prepared AS prepared_age,
owner, database
FROM pg_prepared_xacts
ORDER BY prepared;
Prepared Transaction 已与原客户端会话分离,不会因连接关闭自动结束;必须由事务协调器明确 COMMIT PREPARED 或 ROLLBACK PREPARED。
Slot 的两类保留:
WITH wal_position AS (
SELECT CASE
WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
ELSE pg_current_wal_lsn()
END AS current_lsn
)
SELECT
s.slot_name, s.slot_type, s.database, s.active,
s.restart_lsn,
pg_size_pretty(
COALESCE(pg_wal_lsn_diff(w.current_lsn, s.restart_lsn), 0)
) AS retained_wal,
s.xmin,
age(s.xmin) AS xmin_age,
s.catalog_xmin,
age(s.catalog_xmin) AS catalog_xmin_age,
s.wal_status,
s.safe_wal_size,
s.invalidation_reason
FROM pg_replication_slots AS s
CROSS JOIN wal_position AS w
ORDER BY s.slot_name;
restart_lsn 决定 WAL 文件能否回收;xmin/catalog_xmin 决定相关 Tuple 能否被 VACUUM 删除。一个 Slot 可以只造成 WAL 堆积,也可以同时固定 Tuple/Catalog 清理边界,必须分别告警。
XID 与 MultiXact 风险:
SELECT
datname,
age(datfrozenxid) AS xid_age,
current_setting('autovacuum_freeze_max_age')::bigint AS xid_trigger,
mxid_age(datminmxid) AS multixact_age,
current_setting('autovacuum_multixact_freeze_max_age')::bigint AS multixact_trigger
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
SELECT
c.oid::regclass AS relation,
age(c.relfrozenxid) AS xid_age,
mxid_age(c.relminmxid) AS multixact_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm', 't')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 50;
环绕排查不要过滤 pg_catalog:系统目录也可能成为数据库冻结下界的来源。Relation 查询只能看到当前连接数据库;数据库级查询必须覆盖 pg_database 中全部数据库,包括不可连接的模板库。
不要只看比例告警,还要看“按当前 XID 消耗速率还剩多少时间”。促销、批处理、重试风暴会显著改变消耗斜率。
5.7 pgstattuple、REINDEX 与在线重建
CREATE EXTENSION IF NOT EXISTS pgstattuple;
-- 大表先用近似扫描;仍会读取一部分页面。
SELECT * FROM pgstattuple_approx('app.orders'::regclass);
-- 精确统计会扫描整个 Relation,成本高于估算视图。
SELECT * FROM pgstattuple('app.orders'::regclass);
-- B-tree 物理密度与碎片。
SELECT * FROM pgstatindex('app.orders_status_idx'::regclass);
-- 只修复索引,不会消除 Heap bloat。
REINDEX INDEX CONCURRENTLY app.orders_status_idx;
REINDEX CONCURRENTLY 允许正常 DML,但需要多阶段扫描、额外磁盘与更长时间,并可能等待旧快照;失败时可能留下 invalid index,需要按日志与目录状态清理。普通 REINDEX 锁更强。
pg_repack 是外部扩展,不是 PostgreSQL Core 的“零锁 VACUUM FULL”。完整表 repack 通常要求主键或覆盖全列且均为 NOT NULL 的唯一索引;它通过日志表/触发器捕获并发 DML、构建副本、追赶变更并最终交换对象。多数阶段锁较轻,但开始和最终交换仍需短暂 ACCESS EXCLUSIVE;持续高写入可能无法追平,且需显著额外磁盘并产生大量 WAL。DDL、复制/CDC、触发器语义和扩展版本都必须预演。
6. 底层原理
6.1 从 UPDATE 到可回收空间的时间线
假设 T1 在 Repeatable Read 中读取行 R0,随后 T2 更新该行并提交:
t0 T1 BEGIN ISOLATION LEVEL REPEATABLE READ
T1 SELECT R0 -- Snapshot S1 能看到 R0
t1 T2 UPDATE R0 -> R1
Heap: R0.xmax = T2, 新增 R1.xmin = T2
若更新索引列或同页无空间:新增索引项指向 R1
t2 T2 COMMIT
新事务看到 R1;T1 仍必须看到 R0
R0 = RECENTLY_DEAD,相对全局地平线不可删除
t3 VACUUM 扫描
看到 T1.backend_xmin/Snapshot 仍老于 T2
不删除 R0;索引中指向 R0 的项也必须保留
t4 T1 COMMIT
旧快照消失
t5 下一次 prune/VACUUM
R0 = DEAD,可移除;空间进入同页复用/FSM
对应索引项在 Index Cleanup 中删除或标记可复用
关键不是“删除事务已经提交多久”,而是系统中是否仍存在有资格看到旧版本的 Snapshot/Horizon。VACUUM 计算的是安全边界,不会为了节省空间破坏 MVCC。
6.2 Page Pruning 与 HOT Pruning
Heap Page 通过 line pointer 指向 Tuple。普通非 HOT 更新常形成:
Index key -> LP1 -> old tuple R0
Index key -> LP2 -> new tuple R1
若满足 HOT 条件——没有修改需要逐行维护的索引键,且同一 Heap Page 有足够空间——索引仍指向 HOT 链根:
Index key -> LP1(root) -> R0 -> R1 -> R2
当 R0/R1 对所有快照都不再可见时,Page Pruning 可把链压缩:
Index key -> LP1(redirect) ------> R2
R0/R1 空间被压实并可在本页复用
这种剪枝可能由普通 SELECT、UPDATE、DELETE 在访问页面时机会性触发,前提是能非阻塞取得 Buffer Cleanup Lock。它只解决该 Heap Page 内部空间与 HOT 链,不遍历、删除普通索引中的死项,因此不能替代 VACUUM。fillfactor 留出页内空间可提高 HOT 概率,但会主动增加初始空间占用;应以 n_tup_hot_upd / n_tup_upd、页密度和实际索引写入成本验证。
6.3 Lazy VACUUM 的阶段
普通 VACUUM 通常按以下状态机运行:
- 初始化:确定 Relation、Buffer Access Strategy、冻结 cutoff、是否需要 aggressive/failsafe、索引清理和截断策略。
- 扫描 Heap:逐页判断 VM、Tuple 可见性和可冻结状态;执行 pruning;记录可删除 Tuple 的 TID;设置或清除 VM 条件。
- 批次边界:当 Dead TID 存储达到内存预算时,暂停 Heap 扫描,进入索引/Heap 清理;之后继续下一批。一个 Vacuum 出现多次
index_vacuum_count往往意味着死项很多或内存预算较小。 - Vacuum Indexes:各索引访问方法按 TID 删除死项。
INDEX_CLEANUP=AUTO可在收益很低时跳过;ON强制尝试;OFF跳过。 - Vacuum Heap:清理已解除索引引用的 line pointer,更新 FSM,并验证 all-visible/all-frozen 条件。
- Cleanup Indexes:做索引访问方法的收尾,例如回收可用页。
- Truncate Heap:若表尾存在连续空页,尝试获取足以安全截断的锁并缩短文件。若高并发或
TRUNCATE OFF,可跳过。 - 更新目录/统计:推进
relfrozenxid、relminmxid,更新统计计数与维护时间。
[PG17+] VACUUM 使用新的 Dead TID 内存管理,pg_stat_progress_vacuum 用 byte 维度报告 max_dead_tuple_bytes、dead_tuple_bytes 和 num_dead_item_ids。这改善了内存利用,但不意味着 maintenance_work_mem 可以无限增大:手工维护并发数、Autovacuum Worker 数和索引类型仍决定总内存上界。
6.4 FSM、Visibility Map 与 Index Only Scan
FSM 与 VM 都是 Relation 的辅助 Fork,但职责完全不同:
- FSM 回答“哪个 Page 可能有足够 free space”;它减少插入/非 HOT 更新寻找页面的扫描成本。
- VM all-visible 回答“这个 Heap Page 上所有 Tuple 对所有当前和未来事务都可见,索引扫描可否跳过 Heap 可见性检查”。
- VM all-frozen 进一步表示该页 Tuple 不再需要未来 anti-wraparound 冻结处理。
DML 会保守地清除 VM bit;只有 VACUUM 在检查整页后设置它。因此一个覆盖索引即便包含查询所需全部列,也只有在相关 Heap Page 为 all-visible 时才能真正减少 Heap Fetch:
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT order_id, created_at
FROM app.orders
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2;
观察 Index Only Scan 的 Heap Fetches。高频更新、Autovacuum 落后或长事务固定地平线时,VM 恢复变慢,计划节点仍叫 Index Only Scan,但实际访问 Heap 的次数可能大幅上升。
6.5 索引清理、表尾截断与“为什么文件没变小”
普通 VACUUM 释放的空间首先留在 Relation 内部复用。原因是中间空页不能简单从操作系统文件中“挖洞”并改变所有 Block Number;索引 TID、Buffer Tag 和 WAL 都依赖稳定的 (block, offset) 定位。只有文件末尾连续空 Block 可以安全截断。
因此应分别问:
- 未来写入能否复用空间? 看 FSM、稳定态文件大小和增长斜率;
- 查询是否仍扫描大量空/低密度页? 看 Buffers、I/O 与
pgstattuple; - 是否必须立即把空间返还文件系统? 若是,才评估表重写、分区替换或在线 repack。
索引也类似:VACUUM 删除死索引项后,页面通常可被后续插入复用,但不保证把索引文件压缩到最小。REINDEX 重新按现存行构建紧凑结构;REINDEX TABLE 只重建该表的索引,不会重写 Heap。
6.6 Vacuum Cost、内存与 Buffer Access Strategy
成本节流为页面操作累计分值,例如命中 shared buffers、从存储读入、弄脏页面的成本不同;达到 vacuum_cost_limit 后睡眠 vacuum_cost_delay。它是反馈式负载平滑,不是精确的 MB/s 限流:缓存冷热、页面是否脏、存储延迟、并行索引清理和 AIO 都会改变同一 cost 下的真实吞吐。
- 手工 VACUUM 默认成本延迟通常为 0,可能更快也更容易冲击前台。
- Autovacuum 默认有专用 delay,并在多个 Worker 间协调全局预算;每表显式 cost 参数会改变这一关系。
maintenance_work_mem影响手工维护;autovacuum_work_mem是每个 Worker 上限。总内存风险近似是“并发维护进程 × 每进程上限”,而非单个参数值。- [PG16+]
vacuum_buffer_usage_limit/BUFFER_USAGE_LIMIT控制维护使用的环形 Buffer Access Strategy。太小会重复读取,太大可能逐出前台热点页;0允许使用任意数量 shared buffers,风险更高。 - 操作系统 Page Cache 仍参与实际文件缓存。即使 VACUUM 使用小 shared-buffer ring,扫描也可能推动 OS Cache,挤出其他工作集。
6.7 Freeze、relfrozenxid 与 datfrozenxid
PostgreSQL 的普通 XID 是 32 位环形空间。可见性比较使用“相对当前 XID 的前后半环”语义,因此某个 Tuple 的插入 XID 不能永远保持为普通历史 XID。VACUUM 对足够老、已提交且以后对所有事务都可见的 Tuple 设置冻结语义,使其不再参与普通 XID 年龄判断。
relfrozenxid 表示该 Relation 中不存在比它更老且仍需冻结的普通 XID,是保守下界,不是逐 Tuple 最小 xmin 的精确聚合。datfrozenxid 是数据库级汇总;集群还需跨数据库检查最老值。推进过程大致为:
扫描所有需要处理的 Heap/TOAST Page
-> 冻结满足 cutoff 的 Tuple
-> 确认没有遗漏更老 XID
-> 推进 pg_class.relfrozenxid
-> 数据库汇总推进 pg_database.datfrozenxid
-> 可截断更老的 pg_xact 状态段
VACUUM (FREEZE) 近似把本次冻结最小年龄设为 0,适合静态装载、模板或明确维护窗口;它不是日常“性能按钮”,会增加页面访问、WAL 与脏页。VACUUM FULL 也不是环绕应急首选:它需要强锁和额外磁盘,可能在最紧急时反而无法完成。
6.8 Anti-Wraparound、Failsafe 与停写边界
当表的 XID/MultiXact 年龄超过 autovacuum_freeze_max_age 或对应 MultiXact 阈值时,即使普通变更阈值未达到,也会启动 anti-wraparound VACUUM。接近 failsafe 年龄后,维护会优先推进冻结,可绕过成本延迟并跳过非必要索引清理/常规 Buffer 策略。
官方保护流程大致经历:
- 提前很久持续告警并强制 aggressive vacuum;
- 距离环绕只剩约四千万个事务时,日志发出更强警告;
- 距离约三百万个事务时,为防止可见性灾难,系统拒绝分配新 XID,写事务和许多维护操作失败。
这些绝对数字属于引擎安全边界,不应拿来设业务告警。业务告警必须更早,并结合 XID/s 消耗速率计算剩余时间。紧急恢复顺序通常是:结束或处理最老长事务、Prepared Transaction 和固定 horizon 的 Slot,再对最老表执行普通 VACUUM;不要在危急时盲目用 FULL 或同时启动大量重写。
6.9 MultiXact 的独立风险
当多个事务对同一 Tuple 持有 FOR SHARE、FOR KEY SHARE 等锁时,Tuple Header 不能只放一个事务 ID,于是引用 MultiXact。MultiXact ID 自身是 32 位,成员列表还存放在独立 SLRU 中:
relminmxid/datminmxid对应其维护下界;mxid_age()衡量年龄;- 热点行、外键检查和大量并发 Tuple Lock 会显著加快成员消耗;
- 即使普通 XID 年龄健康,MultiXact 也可能先成为瓶颈;
- 极端成员文件增长可能先触发更积极维护或存储压力。
因此“只监控 age(datfrozenxid)”是不完整的。
6.10 四类清理地平线阻塞者
长事务与 idle in transaction
长事务可能保留 Snapshot 和 backend_xmin。即使它只读一行,影响范围也可能是整个数据库中在其 horizon 之后删除的版本。idle in transaction 还会占用连接、持锁,并把应用网络等待变成数据库事务时间。
Prepared Transaction
PREPARE TRANSACTION 把事务状态持久化。原连接消失不代表事务结束;未决 2PC 可长期持有 XID、锁和可见性影响。没有可靠协调器时,应保持 max_prepared_transactions=0。
Replication Slot
必须拆成两个问题:
restart_lsn --------------> WAL 文件保留、pg_wal/归档压力
xmin/catalog_xmin --------> 普通表/系统目录 Tuple 不能清理
物理 Slot 常见主要风险是 WAL 保留;逻辑 Slot 通常还会持有 catalog_xmin。具体状态以视图为准,不能仅凭 slot_type 推断。
hot_standby_feedback
开启后,Standby 将其最老查询 horizon 反馈给 Primary,Primary 避免清理副本仍可能读取的版本,从而减少 recovery conflict 和查询取消;代价是副本上的一个长查询可以把主库膨胀问题放大。关闭反馈则把成本转化为 Standby 查询取消,由 max_standby_streaming_delay 等参数控制等待边界。
6.11 VACUUM、WAL 与复制延迟
“VACUUM 只是删垃圾,不产生 WAL”是错误的。普通 VACUUM 可能为以下操作写 WAL:
- Heap Page pruning/freeze 与可见性状态变化;
- Visibility Map 状态更新;FSM 本身通常不依赖 WAL 持久化,可在恢复后重建;
- 索引死项清理和页面回收;
- Relation 尾部截断;
- Checkpoint 后首次修改页面可能触发 Full Page Image。
VACUUM 还会弄脏数据页,之后由后台写出或 Checkpoint 刷盘。因此大规模维护会与前台争用 I/O,增加 WAL 归档量,并让物理副本承受重放 CPU/I/O。副本上的查询可能与 Heap 清理、VM bit 变化或锁发生 recovery conflict;开启 feedback 能减少取消,却可能反向阻塞主库回收。
在逻辑复制中,VACUUM 本身不是业务 DML,不会作为逐行删除发送;Publisher 与 Subscriber 各自负责本地 VACUUM、冻结和膨胀治理。物理复制则重放主库相关 WAL,空间与维护状态更紧密地跟随主库。
6.12 ANALYZE 与 VACUUM 的边界
VACUUM (ANALYZE) 只是连续执行两类任务:VACUUM 管理物理版本/冻结,ANALYZE 采样并更新 Planner 统计。它们触发公式不同:大量 INSERT 可能急需 ANALYZE 但几乎没有 Dead Tuple;频繁 DELETE 可能同时需要两者。
分区表要特别注意:各叶子分区有自己的 Autovacuum/Autoanalyze,但仅由分区变化未必自动分析分区父表,涉及父表统计的查询应制定手工 ANALYZE 策略。[PG18] VACUUM/ANALYZE 默认处理继承子表;使用 ONLY 可恢复只处理目标 Relation 的行为,升级脚本必须核对维护范围。
7. 内部数据结构和状态
| 对象/状态 | 本章相关字段或结构 | VACUUM 作用 | 主要观测入口 |
|---|---|---|---|
| Heap Tuple Header | xmin、xmax、ctid、infomask、HOT 标志 | 判断 LIVE/RECENTLY_DEAD/DEAD,Freeze,修剪 HOT 链 | pageinspect、源码、实验 |
| Line Pointer | NORMAL/REDIRECT/DEAD/UNUSED | prune 后重定向或释放 offset | heap_page_items() |
| Heap Page | Page Header、item array、Tuple 区 | prune、压实、冻结、设置可见性 | pgstattuple、Buffers |
| Index Tuple | key + Heap TID | 删除对 Dead Heap Tuple 的引用;页面供复用 | pgstatindex、索引大小 |
| Visibility Map | all-visible/all-frozen bit | VACUUM 验证后设置,DML 清除 | pg_visibility 扩展、Heap Fetches |
| Free Space Map | Page free-space tree | VACUUM/插入更新估算可用空间 | pg_freespacemap 扩展 |
| Snapshot/ProcArray | xmin/xmax/xip、Backend xmin | 决定旧版本是否可移除 | pg_stat_activity.backend_xmin |
| Global visibility state | 全局 OldestXmin/可见性边界 | 将 Recently Dead 转为可删 Dead | VACUUM VERBOSE、源码 |
| Dead TID Store | 待索引清理的 (block, offset) 集合 | 分批驱动 index/heap vacuum | pg_stat_progress_vacuum |
| Buffer Descriptor | pin、usage count、dirty、content lock | 页面扫描、清理与脏写 | pg_stat_io、wait events |
| Memory Context | Vacuum/Index bulk-delete 内存 | 受维护内存与索引实现影响 | Backend memory 观测/日志 |
| WAL Record/LSN | Heap freeze/clean、VM、Index、truncate | 支持崩溃恢复与物理复制 | pg_stat_wal、LSN diff |
pg_class | reltuples、relpages、relallvisible、relallfrozen、relfrozenxid、relminmxid | 阈值估算、VM/Frozen 汇总 | 系统目录 |
pg_database | datfrozenxid、datminmxid | 数据库级环绕下界 | age()、mxid_age() |
| Table Stats | n_dead_tup、维护时间/次数、DML counters | 调度依据和趋势观测 | pg_stat_all_tables |
| Autovacuum Worker | PID、当前 Relation、phase | 执行普通/反环绕维护 | pg_stat_activity、progress/log |
锁方面,普通 VACUUM 使用允许正常 SELECT/INSERT/UPDATE/DELETE 的较弱表锁,但会与部分 DDL、VACUUM FULL、CLUSTER 等冲突;它还需短时页面级锁和 Buffer Cleanup Lock。尾部截断阶段可能尝试更强锁。VACUUM FULL 从开始到结束需要 ACCESS EXCLUSIVE。
8. 场景和选型决策
| 业务场景 | 推荐方案 | 不推荐方案 | 原因 | 性能代价 | 并发代价 | 一致性代价 | 高可用代价 | 运维复杂度 |
|---|---|---|---|---|---|---|---|---|
| 高频更新、文件大小稳定、空间可复用 | 调低该表触发阈值;普通 VACUUM;优化 HOT/fillfactor | 定期 FULL | 稳态 bloat 不等于必须缩文件 | 持续可控 I/O/WAL | 低到中 | 无业务语义变化 | 可控复制负载 | 中 |
| 超大表默认比例触发过晚 | 按变更速率与垃圾窗口设置每表 threshold/scale/max cap [PG18] | 只增加 Worker | 调度晚比 Worker 少更根本 | 更频繁但每次更小 | 低 | 无 | 平滑 WAL 更利于副本 | 中 |
| 只读/append-only 表需要冻结 | 插入触发 VACUUM;装载后 VACUUM (FREEZE, ANALYZE);分区封存 | 永久禁用 Autovacuum | 无 UPDATE 仍需 VM/Freeze | 一次顺序扫描与 WAL | 低 | 无 | 产生维护 WAL | 低到中 |
| Heap 已严重膨胀且必须返还磁盘 | 维护窗 VACUUM FULL,或在线 pg_repack/分区替换 | 期待普通 VACUUM 缩到最小 | 中间空页不能直接截掉 | 高 CPU/I/O/WAL/额外盘 | FULL 高;repack 中 | 交换前需严格验证 | 可能造成大复制延迟 | 高 |
| 仅 B-tree 索引膨胀 | REINDEX CONCURRENTLY,先确认索引确有问题 | VACUUM FULL 全表 | 目标只在索引 | 双扫描、额外盘/WAL | 低到中,最终锁 | 构建期间需唯一性验证 | 重放压力 | 中 |
| 接近 XID 环绕 | 清除 horizon blocker;让 anti-wrap vacuum 完成;逐表普通 VACUUM | FULL、并发大量重写、关闭 Autovacuum | 安全目标是尽快推进 freeze 下界 | 高但必要 | 可能挤压前台 | 防止可见性灾难 | 主从都须监控年龄/lag | 高 |
| Standby 长查询频繁取消 | 有界 feedback + 查询超时/只读工作负载隔离 | 无期限 hot_standby_feedback=on | 在副本取消与主库 bloat 间取舍 | 主库空间/扫描成本可能上升 | 低锁、高 horizon 影响 | 读一致性窗口更长 | lag 与 failover 风险上升 | 高 |
| 逻辑 Slot 离线 | 恢复消费者、推进/重建 Slot,分别看 WAL 与 catalog_xmin | 只扩 pg_wal 磁盘 | Slot 可能同时阻塞 WAL 与目录清理 | 存储/WAL/目录膨胀 | 间接 | 需评估丢失 CDC 位点 | 故障切换与 Slot 连续性复杂 | 高 |
| 写峰值导致 Vacuum 追不上 | Admission Control、削峰、增加可承受 Worker/I/O、每表调度 | 无限 goroutine/连接与 Worker | 垃圾生成速率必须低于清理能力 | 限制峰值换稳定 P99 | 降低锁/WAL 排队 | 业务需幂等与背压 | 减少 lag/归档失速 | 中到高 |
9. 高性能分析
9.1 成本模型
一次 VACUUM 的近似资源量可分解为:
Heap 扫描字节
+ 需要清理的索引扫描/随机访问
+ Heap/VM/FSM 脏写
+ WAL 与 Full Page Image
+ OS 回写与 Checkpoint
+ Replica replay
其中 CPU 用于 Tuple 可见性判断、HOT 链处理、索引回调、WAL 组装和压缩;内存主要用于 Dead TID、索引 bulk-delete 状态和 Buffer descriptors;网络通常不是主库本地瓶颈,但物理复制、归档上传、监控采集和云存储路径会把 WAL 量转化为网络成本。
9.2 shared buffers、OS Page Cache 与随机/顺序 I/O
Heap 扫描偏顺序,但索引清理可能按 TID/索引结构产生更离散访问。较小 Buffer Access Strategy 可避免扫描污染 shared buffers,却不能保证 OS Cache 不被推动。判断瓶颈应结合:
pg_stat_io的 reads、writes、extends、evictions、reuse、read/write time;- 设备队列深度、吞吐、P95/P99 时延与 fsync;
- VACUUM progress 的扫描速度与
delay_time; - 前台查询 shared/local/temp blocks 与 wait events;
- Checkpoint 写入速率与
buffers_backend等对应版本指标。
9.3 [PG18] AIO 的作用边界
PG18 的异步 I/O 子系统允许符合条件的读取并发提交,io_method 可选择 worker、io_uring(构建支持时)或同步执行,effective_io_concurrency/maintenance_io_concurrency 默认基线也发生变化。VACUUM 和其他维护扫描可从更深的 I/O 队列、合并读取和较少等待中受益,但 AIO 不会消除:
- Tuple 可见性与索引清理 CPU;
- 脏页写回、WAL flush 和 Checkpoint;
- Buffer 锁、Relation 锁和旧快照地平线;
- 存储本身的吞吐/时延上限。
在低时延本地 NVMe、网络块存储、旋转盘和云突发型卷上,最优并发深度不同。必须用维护窗口中的真实前台 SLO 验证,而不是因为 AIO 存在就盲目提高并发。
9.4 WAL、Checkpoint 与延迟分位数
大批冻结、索引清理或重写会增加 WAL,可能触发更频繁 Checkpoint 或让 Checkpoint 写入集中。平均 TPS 看似不变时,前台 P99 仍可能因以下原因恶化:
- Backend 找不到 clean buffer,被迫自己写脏页;
- 存储队列被维护 I/O 占满;
- Checkpoint 后 FPI 增多,WAL/网络突发;
- Autovacuum Worker 与前台争用 CPU;
- Replica lag 导致只读流量回切主库或同步复制提交等待。
9.5 读放大、写放大、空间放大
- 读放大:Seq Scan 读取低密度 Heap Page;索引层级变深;Index Only Scan 出现 Heap Fetch;缓存命中率下降。
- 写放大:每次非 HOT UPDATE 写新 Heap Tuple、多个 Index Tuple、WAL,随后 VACUUM 再写清理记录;重写又复制全部存活数据。
- 空间放大:Heap dead/free space、Index 低密度、TOAST 旧版本、WAL/Slot 保留、重写临时副本同时存在。
真正目标不是把 dead_pct 永远压到 0,而是让垃圾生成与回收达到稳定平衡,并满足查询延迟、磁盘增长和复制 SLO。
9.6 参数推导而非固定答案
对某表先测:
R = 峰值不可见版本生成速率(tuples/s)
W = 允许的最大垃圾窗口(s)
D = R × W = 目标触发 dead tuples
N = reltuples
再选择 threshold + scale_factor × N ≈ D,并确保单次 VACUUM 在下一窗口前能完成。还要核对 Worker 排队、I/O 容量和高峰重叠。对 24×7 超大表,较小 scale factor + 合理 base threshold + [PG18] max threshold 往往比单一比例更可预测,但具体值必须由 R、W、行宽、索引数和硬件决定。
Temporary File 通常不是 VACUUM 主路径,但 VACUUM FULL/在线重写周边的索引构建、排序、诊断查询和外部工具可能使用临时空间;不要只看 Relation 增量而忽略临时目录、WAL 和归档共同的峰值磁盘。
10. 高并发分析
10.1 数据库并发与应用并发不是同一个数
goroutine 数 >= 排队请求数 + 活跃请求数
连接池连接数 >= 空闲连接 + 执行 SQL/事务的连接
数据库活跃查询数 <= 已获取连接数
TPS = 单位时间完成事务,不等于上述任一并发数
无限 goroutine 会在 pgxpool 获取连接处排队;若每个请求又持有长事务,数据库同时承受连接占用、Snapshot horizon、锁、WAL 和 Autovacuum 落后。健康检查器和业务代码都应有有界并发、获取/查询超时和背压。
10.2 MVCC、锁与 Vacuum 的交互
普通 VACUUM 不会像 FULL 一样阻塞日常 DML,但它会:
- 与需要更强 Relation Lock 的 DDL 互斥;
- 在 Buffer Cleanup Lock 上短时竞争,繁忙页面可能暂时无法 prune;
- 清理索引时增加索引页面 latch/I/O 压力;
- 尾部截断时尝试较强锁,造成短时队列;
- 被长事务“逻辑阻塞”——不是等待锁,而是无法删除数据。
最后一种最容易误判:pg_stat_progress_vacuum 可能持续前进且无 wait_event,但大量 Tuple “dead but not yet removable”。
10.3 热点行、热点索引页与 MultiXact
热点行上频繁 Tuple Lock 会创建 MultiXact;频繁更新又持续产生版本。递增键索引的右侧页可能成为插入热点,VACUUM/索引清理与前台修改共享页面级同步。优化手段包括分片计数器、事件追加后异步聚合、缩短事务、避免不必要索引、提高 HOT 比例和按业务键分散写入,但每种方案都改变一致性与读路径。
10.4 阻塞队列、死锁与重试风暴
VACUUM 本身通常不是死锁主角,但 VACUUM FULL/REINDEX/DDL 的强锁会进入锁队列。一个长 ACCESS SHARE 事务挡住待执行 FULL 后,后续与 FULL 冲突的请求可能排在其后形成 lock convoy。应用看到超时后若无上限地重试,会进一步占连接、写日志和加剧队列。
维护操作应设置有意识的 lock_timeout,失败后退出并重新排期,而不是无限等待;业务重试只针对明确 SQLSTATE,且必须有指数退避、抖动和全局 admission control。
10.5 事务边界、外部调用与幂等
最危险的模式之一是:
BEGIN
SELECT/UPDATE
调用慢 RPC、等待用户、发送大文件
COMMIT
它把不受数据库控制的延迟纳入 Snapshot 和锁生命周期。应先完成可移出的外部工作,再开启最短事务;确需跨系统一致性时使用 Outbox、Saga 或可靠 2PC 协调器,并为超时/重试设计幂等键。连接池层面必须监控 Acquire Duration、Acquired/Idle/Total Conns,避免把数据库慢误诊为“需要更多连接”。
11. 高可用分析
11.1 RPO/RTO 的关系
VACUUM 不直接改变已提交事务的耐久性,所以对 RPO 多为间接影响;但它产生的 WAL 可压垮归档或复制链路,导致可用恢复点落后。对 RTO 的影响更直接:膨胀会扩大备份、恢复、缓存预热、校验和故障切换后的工作集;接近环绕时,故障切到同样老化的副本不能消除风险。
11.2 物理复制、同步复制与只读副本
- 异步复制:维护 WAL 突发会扩大 replay lag,故障切换可能增加数据损失窗口。
- 同步复制:若同步 Standby 重放/接收或存储受压,提交延迟可能上升;具体取决于
synchronous_commit等待级别。 - Hot Standby:清理 WAL 与长查询冲突时,要在取消查询、延迟重放和 feedback 导致主库 bloat 之间选择。
- Planned Switchover:切换前检查 XID/MultiXact age、slot、WAL lag、正在进行的 FULL/repack/reindex 和归档余量,避免把维护风暴带入角色切换。
- Unplanned Failover:新主库接管后重新确认 Autovacuum Worker、每表参数、Slot/订阅和只读流量路由;旧连接可能仍指向旧主,必须依赖 fencing 防止双写。
11.3 Backup、PITR 与数据校验
物理备份会复制膨胀后的文件,导致备份窗口、对象存储成本和恢复时间增加。普通 VACUUM 回收内部空间后,已有物理文件仍可能保持大;只有重写/截断才明显缩小后续备份。FULL、REINDEX 和 repack 的 WAL 必须能被归档链路及时吸收,否则 PITR 的 RPO/RTO 反而恶化。
修复后不能只看表变小,还应执行:
- 备份成功与恢复演练;
- 物理副本追平、时间线与 Slot 状态核验;
amcheck/校验策略;- 核心查询计划与行数估算;
- 应用重连和事务结果不确定场景验证。
11.4 逻辑复制与 CDC
Publisher 和 Subscriber 的 Tuple 生命周期独立,二者都需要本地 Autovacuum。逻辑 Slot 的 catalog_xmin 可阻塞 Publisher 系统目录清理;失联消费者还可能通过 restart_lsn 堆积 WAL。执行在线重建、分区交换或扩展工具前,应验证 CDC 对 DDL、OID/relfilenode 变化、触发器和复制身份的处理,不得假定“在线”就等于“对所有复制拓扑透明”。
11.5 Failback、脑裂与 Fencing
VACUUM 不是脑裂防护机制。Failover 后若旧 Primary 未被 fencing,两个节点都接受写入,即使各自 VACUUM 正常也无法合并冲突历史。Failback 需要重新建立数据一致性,而不是简单把旧节点切回。维护脚本必须通过角色检测、租约或编排系统确认目标是当前合法 Primary,避免在错误节点执行 FULL/repack 或删除 Slot。
12. 三维影响矩阵
| 维度 | 相关度 | 核心收益 | 主要风险 | 关键指标 |
|---|---|---|---|---|
| 高性能 | 高 | 控制 Heap/Index/VM 退化,维持计划与缓存效率 | 维护 I/O、WAL、Checkpoint、缓存污染、重写峰值 | dead 生成/清理速率、Relation/Index size、Heap Fetches、Buffers、WAL/s、P95/P99 |
| 高并发 | 高 | MVCC 下无阻塞回收旧版本,减少页/索引压力 | 长事务固定 horizon;FULL/DDL 锁队列;Worker 与前台竞争 | backend_xmin、xact age、wait events、blocked count、pool acquire、MultiXact age |
| 高可用 | 中到高 | 降低备份/恢复工作集,避免环绕停写 | 复制冲突/lag、Slot 保留、归档失速、切换时维护放大 | replay lag、retained WAL、xmin/catalog_xmin、archive failures、XID/MXID time-to-limit |
13. PostgreSQL 14—18 重要差异
| 版本 | 与本章相关的重要变化 | 升级/兼容注意 |
|---|---|---|
| PG14 | Vacuum failsafe;INDEX_CLEANUP=AUTO;更积极的 B-tree dead item 复用;PROCESS_TOAST;更强环绕保护 | 14 已具备本章主要安全模型;不要把后续版本进度字段直接用于 14 |
| PG15 | VACUUM VERBOSE/Autovacuum 日志信息增强;统计系统改为共享内存;更积极推进 frozen/MultiXact 下界 | 统计刷新语义和日志基线与旧版本不同;升级后重建监控基线 |
| PG16 | vacuum_buffer_usage_limit 与 VACUUM/ANALYZE BUFFER_USAGE_LIMIT;BRIN 索引列更新在条件满足时可保持 HOT | 运维脚本可显式限制 Buffer ring,但跨版本执行前检查语法 |
| PG17 | VACUUM 新内存管理;进度字段改为 byte 与 item-id:max_dead_tuple_bytes、dead_tuple_bytes、num_dead_item_ids;移除 old_snapshot_threshold | 旧监控字段名会报错;升级扩展/仪表盘必须同步修改 |
| PG18 | AIO 可加速部分维护 I/O;autovacuum_worker_slots;autovacuum_vacuum_max_threshold;vacuum_truncate GUC;eager freezing 及 vacuum_max_eager_freeze_failure_rate;表统计新增累计维护耗时;progress/VERBOSE 可报告 cost delay;pg_signal_autovacuum_worker 角色;VACUUM/ANALYZE 默认递归处理继承子表 | Worker 槽位是启动期容量,autovacuum_max_workers 可在槽位范围内运行时调整;升级后检查维护作用域、告警字段和阈值 cap |
PG18 eager freezing 会尝试冻结部分已 all-visible 的页面,以减少未来 aggressive scan,但受失败率参数控制。它改变维护时机,不改变“所有可见性判断必须安全”的原则。pg_signal_autovacuum_worker 只降低管理权限门槛,不代表可以随意终止 anti-wraparound Worker。
14. 实验
三个实验都会制造额外磁盘、WAL 和 I/O。只能在隔离测试集群执行;先确认 tablespace、
pg_wal、归档和副本空间。所有耗时必须在你的环境实测,禁止把本章示例规模或结果当成固定基准。
14.1 实验一:高频 UPDATE 制造 Heap 与 Index Bloat
14.1.1 实验目标
验证以下结论:
- 非 HOT UPDATE 会同时产生旧 Heap Tuple 和新 Index Tuple;
n_dead_tup是估算,pgstattuple_approx/pgstattuple是更直接但成本更高的扫描;- 普通 VACUUM 清理后,dead tuple 显著下降,但 Heap/Index 文件通常不会按相同比例缩小;
- 清理后空间可由后续写入复用,增长斜率比单次文件大小更能说明问题;
- VM 恢复后,Index Only Scan 的 Heap Fetches 可能下降。
14.1.2 版本、扩展与实验记录
- PostgreSQL:14—18;建议 PG18;
- 必要扩展:无;精确/近似 bloat 观测可选
pgstattuple; - 可选:
pg_stat_statements与系统 I/O 监控; - 记录:
SELECT version()、相关SHOW、CPU/内存/存储、缓存冷热状态、并发数、行数、平均行宽、测试持续时间、前台 P50/P95/P99、Buffers、WAL bytes、CPU、I/O、Wait Event。
CREATE SCHEMA IF NOT EXISTS vacuum_lab;
CREATE EXTENSION IF NOT EXISTS pgstattuple;
DROP TABLE IF EXISTS vacuum_lab.churn;
CREATE TABLE vacuum_lab.churn (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL,
status integer NOT NULL,
payload text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
) WITH (
autovacuum_enabled = false, -- 仅实验表;实验结束必须恢复/删除
fillfactor = 100
);
CREATE INDEX churn_status_idx
ON vacuum_lab.churn (tenant_id, status, id);
INSERT INTO vacuum_lab.churn (tenant_id, status, payload)
SELECT
1 + (g % 100),
g % 20,
repeat(md5(g::text), 6)
FROM generate_series(1, 300000) AS g;
ANALYZE vacuum_lab.churn;
CHECKPOINT; -- 仅隔离环境;用于记录明确的实验起点,不是生产调优手段
基线:
SELECT
pg_size_pretty(pg_relation_size('vacuum_lab.churn')) AS heap,
pg_size_pretty(pg_indexes_size('vacuum_lab.churn')) AS indexes,
pg_size_pretty(pg_total_relation_size('vacuum_lab.churn')) AS total;
SELECT * FROM pgstattuple_approx('vacuum_lab.churn'::regclass);
SELECT * FROM pgstatindex('vacuum_lab.churn_status_idx'::regclass);
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT id, status
FROM vacuum_lab.churn
WHERE tenant_id = 42 AND status = 7
ORDER BY id
LIMIT 500;
14.1.3 Session A:制造版本链和索引垃圾
每条 UPDATE 在 psql autocommit 模式下单独提交。必须修改索引列 status,从而刻意阻止 HOT:
UPDATE vacuum_lab.churn
SET status = (status + 1) % 20,
payload = md5(payload || clock_timestamp()::text),
updated_at = clock_timestamp();
-- 重复执行 6—10 轮;每轮都是一次独立提交。
-- 可在每轮后记录 pg_current_wal_lsn(),不要把轮数当作生产建议。
若要用 EXPLAIN ANALYZE 分析 UPDATE,它会真正执行:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
UPDATE vacuum_lab.churn
SET status = (status + 1) % 20,
updated_at = clock_timestamp()
WHERE id BETWEEN 1 AND 10000;
ROLLBACK;
本实验没有触发器或外部副作用,但一般情况下 ROLLBACK 不保证撤销 Sequence 消耗、外部调用或非事务性副作用。
14.1.4 Session B:并行观测
在 A 每轮提交后执行:
SELECT pg_stat_clear_snapshot();
SELECT
relid::regclass,
n_live_tup,
n_dead_tup,
n_tup_upd,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_pct,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE relid = 'vacuum_lab.churn'::regclass;
SELECT
pg_relation_size('vacuum_lab.churn') AS heap_bytes,
pg_indexes_size('vacuum_lab.churn') AS index_bytes,
pg_current_wal_lsn() AS current_lsn;
SELECT * FROM pgstattuple_approx('vacuum_lab.churn'::regclass);
SELECT pid, state, wait_event_type, wait_event, left(query, 120)
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY pid;
Session B 的查询不应等待 A 的已提交轮次;若在 A 的单次大 UPDATE 期间执行,统计可能尚未包含未提交结果,但普通观测不会因行锁而阻塞。
14.1.5 Session C:普通 VACUUM
A 停止并确认最后一轮提交后:
VACUUM (VERBOSE, ANALYZE) vacuum_lab.churn;
另一个会话观察:
SELECT *
FROM pg_stat_progress_vacuum
WHERE relid = 'vacuum_lab.churn'::regclass;
预期没有失败或长锁等待;若同表已有 VACUUM、DDL 或表重写,C 可能等待 Relation Lock,应通过 pg_blocking_pids(pid) 定位而不是盲目终止。
14.1.6 明确时间线
t0 建表、装载、ANALYZE、记录基线
t1 A: UPDATE 第 1 轮并 COMMIT
t2 B: 记录 dead estimate、size、WAL、等待
t3 A/B 重复若干轮
t4 A: 最后一轮 COMMIT;停止写入
t5 C: 普通 VACUUM ANALYZE;正常情况下不等待业务锁
t6 B: 再次记录 size、pgstattuple、计划与 Heap Fetches
t7 A: 再执行若干轮相同 UPDATE,验证旧空间是否复用
- 等待步骤:正常情况下无;只可能出现资源争用或同类维护/DDL 锁等待。
- 失败步骤:正常情况下无;磁盘不足、statement timeout 或权限错误属于环境失败。
- 提交步骤:A 每轮 UPDATE 自动提交;C 的 VACUUM 独立执行,不能包在事务块。
14.1.7 预期结果与解释
n_tup_hot_upd接近 0,因为更新了索引键;n_dead_tup、Heap/Index size 和 WAL 增长,但统计值与精确扫描不完全相同;- 普通 VACUUM 后
n_dead_tup估算和dead_tuple_percent下降; pg_relation_size/pg_indexes_size通常不会同比缩小,说明空间已内部复用而非返还 OS;- 后续同等 UPDATE 可能更多复用 Heap/Index free space,文件增长速度低于第一次;
- VACUUM 设置更多 all-visible bit 后,读查询
Heap Fetches可能下降;随后 UPDATE 会再次清除相关 VM bit。
结果表必须由你填写:
| 阶段 | Heap bytes | Index bytes | Dead tuple estimate | pgstattuple dead/free % | WAL 增量 | P50/P95/P99 | CPU | 读/写 IOPS | 主要 Wait |
|---|---|---|---|---|---|---|---|---|---|
| 基线 | |||||||||
| UPDATE 后 | |||||||||
| VACUUM 后 | |||||||||
| 再次 UPDATE 后 |
14.1.8 清理与生产安全警告
DROP TABLE IF EXISTS vacuum_lab.churn;
生产环境不得用“关闭 Autovacuum + 全表反复 UPDATE”做演练;不要在高峰对大表执行精确 pgstattuple、CHECKPOINT 或无条件 UPDATE。先在克隆数据、隔离副本或专用压测环境评估 WAL、磁盘和归档上限。
14.2 实验二:长事务让旧版本停留在 Recently Dead
14.2.1 实验目标
复现一个没有锁阻塞、却能让 VACUUM 无法回收旧版本的长事务;识别 backend_xmin、xact_start、idle in transaction 和 VACUUM VERBOSE 中的不可移除版本。
14.2.2 版本、扩展与准备
- PostgreSQL:14—18;
- 必要扩展:无;
pgstattuple可选; - 三个独立 psql 会话 A/B/C。
DROP TABLE IF EXISTS vacuum_lab.old_snapshot;
CREATE TABLE vacuum_lab.old_snapshot (
id bigint PRIMARY KEY,
payload text NOT NULL
) WITH (autovacuum_enabled = false);
INSERT INTO vacuum_lab.old_snapshot
SELECT g, repeat(md5(g::text), 4)
FROM generate_series(1, 200000) AS g;
ANALYZE vacuum_lab.old_snapshot;
14.2.3 Session A:固定快照后变成 idle in transaction
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM vacuum_lab.old_snapshot;
SELECT txid_current_if_assigned(), pg_backend_pid();
-- 此后不要执行 COMMIT;让会话停在提示符。
A 的第一条 SELECT 建立事务级快照。命令完成后,pg_stat_activity.state 变为 idle in transaction,但快照仍保留。
14.2.4 Session B:删除并提交
BEGIN;
DELETE FROM vacuum_lab.old_snapshot
WHERE id <= 150000;
COMMIT;
B 的 DELETE 取得行锁并修改 xmax,但 A 只读取且不持有冲突行锁,所以 B 正常提交。对新事务而言这些行已删除;对 A 的旧快照而言仍可见。
验证:
-- Session B 或新会话:
SELECT count(*) FROM vacuum_lab.old_snapshot; -- 预期 50000
-- Session A:
SELECT count(*) FROM vacuum_lab.old_snapshot; -- 仍预期 200000
14.2.5 Session C:VACUUM 与诊断
SELECT
pid, state, xact_start,
clock_timestamp() - xact_start AS age,
backend_xmin,
age(backend_xmin) AS xmin_age,
wait_event_type, wait_event,
left(query, 160)
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;
VACUUM (VERBOSE, ANALYZE) vacuum_lab.old_snapshot;
VACUUM 正常完成,并不会等待 A;但 VERBOSE/日志应显示存在 dead but not yet removable 的版本或相应不可回收计数。继续观测:
SELECT
n_live_tup, n_dead_tup,
last_vacuum, vacuum_count,
pg_relation_size('vacuum_lab.old_snapshot') AS heap_bytes
FROM pg_stat_user_tables
WHERE relid = 'vacuum_lab.old_snapshot'::regclass;
SELECT * FROM pgstattuple_approx('vacuum_lab.old_snapshot'::regclass);
n_dead_tup 与扩展输出受统计/扫描语义影响,不要用单一数字证明 “Recently Dead”;最直接证据是 A 的旧快照仍能读到 200000 行、A 的 backend_xmin、以及 VACUUM 不能移除这些版本。
14.2.6 释放地平线并再次清理
Session A:
COMMIT;
Session C:
VACUUM (VERBOSE, ANALYZE) vacuum_lab.old_snapshot;
再次检查统计和扩展;旧版本现在可移除。Heap 文件可能仍保持原大小,但内部 free space 增加。
14.2.7 明确时间线
t0 准备 200000 行
t1 A: BEGIN REPEATABLE READ;SELECT 建立 S1;保持 idle in transaction
t2 B: DELETE 150000 行;COMMIT 成功
t3 A: 仍看到 200000;新会话看到 50000
t4 C: VACUUM 完成但不能移除 A 仍可见的旧版本;不等待、不失败
t5 A: COMMIT,释放 Snapshot/horizon
t6 C: 第二次 VACUUM,旧版本转为可移除并清理
- 等待步骤:无预期锁等待;A 是逻辑地平线阻塞者。
- 失败步骤:无预期失败。若配置了
idle_in_transaction_session_timeout,A 可能被服务器终止,这是保护机制。 - 提交步骤:B 在 t2 提交;A 在 t5 提交;VACUUM 自身不能置于显式事务块。
14.2.8 结果解释与生产警告
这个实验说明“VACUUM 没有等待”不代表“VACUUM 清干净了”。生产中应同时告警长事务持续时间、backend_xmin 年龄和表维护债务;仅告警锁等待会漏掉这种故障。终止生产会话前要确认事务所有者、业务幂等性和回滚成本;Prepared Transaction 不能用 pg_terminate_backend 解决,必须由 2PC 协调器决议。
DROP TABLE IF EXISTS vacuum_lab.old_snapshot;
14.3 实验三:比较 VACUUM、VACUUM FULL、REINDEX 与 pg_repack
14.3.1 实验目标
对同一类膨胀分别验证:
- 普通 VACUUM:在线清理和内部复用,不保证缩文件;
- REINDEX CONCURRENTLY:只紧凑索引,不处理 Heap;
- VACUUM FULL:重写 Heap/索引并返还空间,但需强锁;
- pg_repack:通过副本与变更日志在线重写,多数时间允许 DML,但不是零锁、零 WAL 或零额外空间。
14.3.2 版本、扩展与准备
- PostgreSQL:14—18;
pgstattuple可选;pg_repack部分需要服务器扩展和匹配版本的客户端二进制;当前官方 1.5 系列支持 PG14—18,执行前仍应核对部署版本;- 建议至少三会话,并准备系统层磁盘/WAL/复制监控。
DROP TABLE IF EXISTS vacuum_lab.method_compare;
CREATE TABLE vacuum_lab.method_compare (
id bigint PRIMARY KEY,
k integer NOT NULL,
payload text NOT NULL,
updated_at timestamptz NOT NULL
) WITH (autovacuum_enabled = false, fillfactor = 100);
CREATE INDEX method_compare_k_idx
ON vacuum_lab.method_compare (k, id);
INSERT INTO vacuum_lab.method_compare
SELECT g, g % 1000, repeat(md5(g::text), 8), clock_timestamp()
FROM generate_series(1, 1000000) AS g;
ANALYZE vacuum_lab.method_compare;
制造非 HOT bloat;每条单独提交,按磁盘容量减少规模:
UPDATE vacuum_lab.method_compare
SET k = (k + 1) % 1000,
payload = md5(payload || clock_timestamp()::text),
updated_at = clock_timestamp();
-- 重复 4—8 轮
统一测量:
SELECT
pg_relation_size('vacuum_lab.method_compare') AS heap_bytes,
pg_indexes_size('vacuum_lab.method_compare') AS index_bytes,
pg_total_relation_size('vacuum_lab.method_compare') AS total_bytes;
SELECT * FROM pgstattuple_approx('vacuum_lab.method_compare'::regclass);
SELECT * FROM pgstatindex('vacuum_lab.method_compare_k_idx'::regclass);
14.3.3 方法 A:普通 VACUUM
Session B:
VACUUM (VERBOSE, ANALYZE) vacuum_lab.method_compare;
Session C 同时运行普通 SELECT/UPDATE,预期可以继续;记录前台 P95/P99、I/O、WAL 和 Wait Event。完成后复测:dead tuple 下降,但 Heap/Index 文件通常不会按比例缩小。
14.3.4 方法 B:REINDEX CONCURRENTLY
再次执行数轮非 HOT UPDATE 制造索引垃圾,然后:
REINDEX INDEX CONCURRENTLY vacuum_lab.method_compare_k_idx;
另一个会话观察:
SELECT * FROM pg_stat_progress_create_index;
SELECT
indexrelid::regclass,
indisvalid,
indisready,
indislive
FROM pg_index
WHERE indrelid = 'vacuum_lab.method_compare'::regclass;
预期业务 DML可继续,但构建消耗双份索引空间、扫描 Heap/Index 并产生 WAL;旧快照可能让某阶段等待。完成后索引文件可能变小,Heap 大小不变。失败时先检查 invalid index,再按对象名和依赖安全删除,不要自动 DROP INDEX 猜测。
14.3.5 方法 C:VACUUM FULL 的锁行为
先再次制造 bloat。Session A 持有普通读锁:
BEGIN;
SELECT count(*) FROM vacuum_lab.method_compare WHERE k = 7;
SELECT pg_backend_pid();
-- 保持事务不提交,可另执行 SELECT pg_sleep(60);
Session B:
SET lock_timeout = '3s';
VACUUM (FULL, VERBOSE, ANALYZE) vacuum_lab.method_compare;
B 预期在获取 ACCESS EXCLUSIVE 时等待,并因 lock_timeout 失败。Session C 定位阻塞链:
SELECT
a.pid,
a.state,
a.wait_event_type,
a.wait_event,
pg_blocking_pids(a.pid) AS blockers,
left(a.query, 180) AS query
FROM pg_stat_activity AS a
WHERE a.datname = current_database();
SELECT
l.pid, l.locktype, l.mode, l.granted,
l.relation::regclass AS relation
FROM pg_locks AS l
WHERE l.relation = 'vacuum_lab.method_compare'::regclass
ORDER BY l.granted DESC, l.pid;
Session A:
COMMIT;
Session B 重新执行前先评估维护窗并重置超时:
RESET lock_timeout;
VACUUM (FULL, VERBOSE, ANALYZE) vacuum_lab.method_compare;
FULL 执行期间,Session C 的普通 SELECT/UPDATE 将等待 ACCESS EXCLUSIVE;表规模足够大时可在 pg_stat_progress_cluster 观察重写。完成后 Heap 与索引通常明显缩小,但会有高 I/O、额外磁盘和 WAL。
14.3.6 方法 D:pg_repack 在线重写
再次制造 bloat。先检查前置条件:
CREATE EXTENSION IF NOT EXISTS pg_repack;
SELECT conname, contype
FROM pg_constraint
WHERE conrelid = 'vacuum_lab.method_compare'::regclass;
SELECT pg_size_pretty(pg_total_relation_size('vacuum_lab.method_compare'));
系统 shell 中先 dry run。连接串由 libpq 环境或安全 secret 注入,不写入脚本/日志:
pg_repack \
--dbname="$DATABASE_URL" \
--table=vacuum_lab.method_compare \
--dry-run
实际执行建议在首次演练中禁止工具主动取消/终止冲突会话:
pg_repack \
--dbname="$DATABASE_URL" \
--table=vacuum_lab.method_compare \
--no-order \
--wait-timeout=5 \
--no-kill-backend
Session C 同时观察业务 DML、锁、repack schema 临时对象、WAL、磁盘和复制延迟。预期多数阶段 INSERT/UPDATE/DELETE 可继续;初始设置和最终 swap 仍需短暂 ACCESS EXCLUSIVE,全表 repack 期间持有 SHARE UPDATE EXCLUSIVE 以禁止目标表 DDL。高写入时日志表可能增长,工具可能追不上。
14.3.7 明确时间线、等待与失败
t0 制造共同 bloat、记录基线
t1 方法 A: VACUUM;前台继续;无预期失败
t2 重新制造 bloat
t3 方法 B: REINDEX CONCURRENTLY;DML 继续,某阶段可能等待旧快照;失败可留 invalid index
t4 重新制造 bloat
t5 A 持普通事务;B 执行 FULL,等待并被 lock_timeout 取消(预期失败)
t6 A COMMIT;B 在维护窗执行 FULL;C 的读写等待 FULL(预期等待)
t7 重新制造 bloat
t8 pg_repack dry-run;再实际执行;初始/最终锁可能等待,--no-kill-backend 下超时则跳过而不杀业务
t9 对比空间、WAL、耗时、P95/P99、复制 lag 和锁影响
14.3.8 对比结论与测量表
| 方法 | Heap 缩小 | Index 缩小 | 日常 DML | 主要锁 | 额外磁盘 | WAL/副本压力 | 失败恢复复杂度 |
|---|---|---|---|---|---|---|---|
| VACUUM | 通常否;仅尾部可截断 | 通常不紧凑,只可复用 | 可继续 | 较弱;截断阶段例外 | 低 | 中,视冻结/清理量 | 低 |
| REINDEX CONCURRENTLY | 否 | 是 | 可继续 | 多阶段短锁/等待旧快照 | 至少新索引空间 | 中到高 | 中;可能留 invalid index |
| VACUUM FULL | 是 | 随表重建 | 全程阻塞 | ACCESS EXCLUSIVE | 新表/索引临时空间 | 高 | 中;事务性重写但窗口风险高 |
| pg_repack | 是 | 是 | 多数阶段可继续 | 开始/交换短 AEL;过程中 SUE | 官方建议约目标表+索引的额外两倍量级 | 高 | 高;外部工具、临时对象、追赶与版本匹配 |
| 方法 | 总时长 | 锁等待 | Heap 前/后 | Index 前/后 | WAL 增量 | 主库 P95/P99 | Replica lag 峰值 | 磁盘峰值 |
|---|---|---|---|---|---|---|---|---|
| VACUUM | ||||||||
| REINDEX CONCURRENTLY | ||||||||
| VACUUM FULL | ||||||||
| pg_repack |
14.3.9 清理与生产警告
DROP TABLE IF EXISTS vacuum_lab.method_compare;
-- 仅确认没有其他对象依赖、也没有残留任务后再决定是否:
-- DROP EXTENSION pg_repack;
生产重写前必须完成:容量峰值计算、归档/副本压测、锁超时策略、长事务清理、CDC/触发器验证、失败清理步骤、回滚/重新调度方案和恢复演练。pg_repack --wait-timeout 默认可能取消乃至终止冲突后端;不理解该行为时必须使用 --no-kill-backend。
15. Go + pgx/v5:VACUUM 健康检查器
这个程序是“采集与告警判定”的参考实现,不自动 VACUUM、终止会话、删除 Slot 或执行重写。自动修复会把一次误报直接变成数据可用性事故,应由 Runbook、权限审批和审计系统控制。
设计要点:
DATABASE_URL只从环境变量读取;- 所有 SQL 使用
$1、$2参数;对象名来自系统目录,不拼接用户输入; - 每个检查都有
context.WithTimeout; - 固定检查集合使用 semaphore 控制并发,不无限创建 goroutine;
- 每个
Rows都显式Close()并检查rows.Err(); - 使用
errors.As和*pgconn.PgError提取 SQLSTATE; - SIGINT/SIGTERM 取消根 Context,等待当次查询服从取消后关闭 Pool;
- 输出包含 pgxpool 快照,区分数据库健康与连接池排队;
- 不使用显式长事务,因为跨多个监控查询保持同一 Snapshot 反而会制造本章所讨论的问题;
- 所有阈值都必须显式注入,程序不提供“万能默认值”。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"sort"
"strconv"
"sync"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Policy struct {
CheckInterval time.Duration
QueryTimeout time.Duration
LongTxThreshold time.Duration
IdleInTxThreshold time.Duration
AutovacuumLag time.Duration
DeadTupleRatio float64
MinTableBytes int64
XIDTriggerRatio float64
MXIDTriggerRatio float64
SlotRetainedWALBytes int64
MaxConcurrentChecks int
DBMaxConns int32
}
type Finding struct {
Check string `json:"check"`
Severity string `json:"severity"`
Object string `json:"object"`
Message string `json:"message"`
Data map[string]any `json:"data,omitempty"`
}
type CheckError struct {
Check string `json:"check"`
Message string `json:"message"`
SQLState string `json:"sqlstate,omitempty"`
}
type PoolSnapshot struct {
AcquiredConns int32 `json:"acquired_conns"`
IdleConns int32 `json:"idle_conns"`
TotalConns int32 `json:"total_conns"`
MaxConns int32 `json:"max_conns"`
AcquireCount int64 `json:"acquire_count"`
AcquireTime time.Duration `json:"acquire_duration"`
}
type Report struct {
At time.Time `json:"at"`
Findings []Finding `json:"findings"`
Errors []CheckError `json:"errors,omitempty"`
Pool PoolSnapshot `json:"pool"`
}
type namedCheck struct {
name string
fn func(context.Context, *pgxpool.Pool, Policy) ([]Finding, error)
}
type checkResult struct {
name string
findings []Finding
err error
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
url := os.Getenv("DATABASE_URL")
if url == "" {
slog.Error("DATABASE_URL is required")
os.Exit(2)
}
policy, err := loadPolicy()
if err != nil {
slog.Error("invalid health policy", "error", err)
os.Exit(2)
}
cfg, err := pgxpool.ParseConfig(url)
if err != nil {
slog.Error("parse DATABASE_URL", "error", err)
os.Exit(2)
}
cfg.MaxConns = policy.DBMaxConns
cfg.MinConns = 0
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
slog.Error("create pgx pool", "error", errorMessage(err))
os.Exit(1)
}
defer pool.Close()
pingCtx, cancel := context.WithTimeout(ctx, policy.QueryTimeout)
err = pool.Ping(pingCtx)
cancel()
if err != nil {
slog.Error("database ping failed", "error", errorMessage(err))
os.Exit(1)
}
checks := []namedCheck{
{name: "transactions", fn: checkTransactions},
{name: "prepared_transactions", fn: checkPreparedTransactions},
{name: "table_vacuum_debt", fn: checkTableVacuumDebt},
{name: "wraparound", fn: checkWraparound},
{name: "replication_slots", fn: checkReplicationSlots},
}
encoder := json.NewEncoder(os.Stdout)
for {
report := runChecks(ctx, pool, policy, checks)
if err := encoder.Encode(report); err != nil {
slog.Error("encode report", "error", err)
return
}
timer := time.NewTimer(policy.CheckInterval)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
slog.Info("shutdown", "reason", ctx.Err())
return
case <-timer.C:
}
}
}
func runChecks(ctx context.Context, pool *pgxpool.Pool, p Policy, checks []namedCheck) Report {
sem := make(chan struct{}, p.MaxConcurrentChecks)
results := make(chan checkResult, len(checks))
var wg sync.WaitGroup
for _, item := range checks {
item := item
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results <- checkResult{name: item.name, err: ctx.Err()}
return
}
checkCtx, cancel := context.WithTimeout(ctx, p.QueryTimeout)
defer cancel()
findings, err := item.fn(checkCtx, pool, p)
results <- checkResult{name: item.name, findings: findings, err: err}
}()
}
wg.Wait()
close(results)
report := Report{At: time.Now().UTC()}
for result := range results {
report.Findings = append(report.Findings, result.findings...)
if result.err != nil {
report.Errors = append(report.Errors, classifyError(result.name, result.err))
}
}
sort.Slice(report.Findings, func(i, j int) bool {
if report.Findings[i].Severity != report.Findings[j].Severity {
return report.Findings[i].Severity < report.Findings[j].Severity
}
if report.Findings[i].Check != report.Findings[j].Check {
return report.Findings[i].Check < report.Findings[j].Check
}
return report.Findings[i].Object < report.Findings[j].Object
})
stat := pool.Stat()
report.Pool = PoolSnapshot{
AcquiredConns: stat.AcquiredConns(),
IdleConns: stat.IdleConns(),
TotalConns: stat.TotalConns(),
MaxConns: stat.MaxConns(),
AcquireCount: stat.AcquireCount(),
AcquireTime: stat.AcquireDuration(),
}
return report
}
func checkTransactions(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
const query = `
SELECT
pid,
COALESCE(datname, ''),
COALESCE(usename, ''),
COALESCE(application_name, ''),
COALESCE(client_addr::text, ''),
state,
extract(epoch FROM clock_timestamp() - xact_start)::float8 AS age_seconds,
COALESCE(age(backend_xmin), 0)::bigint AS xmin_age,
COALESCE(wait_event_type, ''),
COALESCE(wait_event, ''),
left(query, 200)
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
AND xact_start IS NOT NULL
AND (
(state LIKE 'idle in transaction%'
AND extract(epoch FROM clock_timestamp() - xact_start) >= $1)
OR (state NOT LIKE 'idle in transaction%'
AND extract(epoch FROM clock_timestamp() - xact_start) >= $2)
)
ORDER BY xact_start`
rows, err := pool.Query(ctx, query, p.IdleInTxThreshold.Seconds(), p.LongTxThreshold.Seconds())
if err != nil {
return nil, err
}
defer finishRows(rows, &err)
for rows.Next() {
var pid int32
var database, user, app, client, state string
var ageSeconds float64
var xminAge int64
var waitType, waitEvent, sample string
if scanErr := rows.Scan(
&pid, &database, &user, &app, &client, &state,
&ageSeconds, &xminAge, &waitType, &waitEvent, &sample,
); scanErr != nil {
return nil, scanErr
}
severity := "warning"
if state == "idle in transaction (aborted)" {
severity = "critical"
}
findings = append(findings, Finding{
Check: "transactions",
Severity: severity,
Object: fmt.Sprintf("pid=%d", pid),
Message: "transaction exceeds the configured policy",
Data: map[string]any{
"database": database, "user": user, "application": app,
"client": client, "state": state, "age_seconds": ageSeconds,
"backend_xmin_age": xminAge, "wait_event_type": waitType,
"wait_event": waitEvent, "query_sample": sample,
},
})
}
return findings, nil
}
func checkPreparedTransactions(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
const query = `
SELECT
gid,
owner::text,
database::text,
extract(epoch FROM clock_timestamp() - prepared)::float8 AS age_seconds
FROM pg_prepared_xacts
WHERE extract(epoch FROM clock_timestamp() - prepared) >= $1
ORDER BY prepared`
rows, err := pool.Query(ctx, query, p.LongTxThreshold.Seconds())
if err != nil {
return nil, err
}
defer finishRows(rows, &err)
for rows.Next() {
var gid, owner, database string
var ageSeconds float64
if scanErr := rows.Scan(&gid, &owner, &database, &ageSeconds); scanErr != nil {
return nil, scanErr
}
findings = append(findings, Finding{
Check: "prepared_transactions",
Severity: "critical",
Object: gid,
Message: "prepared transaction exceeds the configured policy; resolve through the transaction coordinator",
Data: map[string]any{
"owner": owner, "database": database, "age_seconds": ageSeconds,
},
})
}
return findings, nil
}
func checkTableVacuumDebt(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
const query = `
WITH global_cfg AS (
SELECT
current_setting('autovacuum_vacuum_threshold')::float8 AS base_threshold,
current_setting('autovacuum_vacuum_scale_factor')::float8 AS scale_factor,
COALESCE(current_setting('autovacuum_vacuum_max_threshold', true), '-1')::float8 AS max_threshold
), base AS (
SELECT
s.relid,
s.relid::regclass::text AS relation,
pg_total_relation_size(s.relid)::bigint AS total_bytes,
s.n_live_tup::bigint,
s.n_dead_tup::bigint,
s.last_vacuum,
s.last_autovacuum,
GREATEST(c.reltuples, 0)::float8 AS reltuples,
COALESCE((
SELECT option_value::float8
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_vacuum_threshold'
), g.base_threshold) AS base_threshold,
COALESCE((
SELECT option_value::float8
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_vacuum_scale_factor'
), g.scale_factor) AS scale_factor,
COALESCE((
SELECT option_value::float8
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_vacuum_max_threshold'
), g.max_threshold) AS max_threshold,
COALESCE((
SELECT option_value::boolean
FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'autovacuum_enabled'
), true) AS autovacuum_enabled
FROM pg_stat_user_tables AS s
JOIN pg_class AS c ON c.oid = s.relid
CROSS JOIN global_cfg AS g
), calc AS (
SELECT
*,
CASE
WHEN max_threshold < 0
THEN base_threshold + scale_factor * reltuples
ELSE LEAST(max_threshold, base_threshold + scale_factor * reltuples)
END AS estimated_trigger,
n_dead_tup::float8 / NULLIF(n_live_tup + n_dead_tup, 0) AS dead_ratio,
extract(epoch FROM clock_timestamp() - COALESCE(
CASE
WHEN last_vacuum IS NULL THEN last_autovacuum
WHEN last_autovacuum IS NULL THEN last_vacuum
ELSE GREATEST(last_vacuum, last_autovacuum)
END,
'epoch'::timestamptz
))::float8 AS maintenance_age_seconds
FROM base
)
SELECT
relation,
total_bytes,
n_live_tup,
n_dead_tup,
COALESCE(dead_ratio, 0)::float8,
estimated_trigger::float8,
(n_dead_tup >= estimated_trigger) AS trigger_exceeded,
maintenance_age_seconds,
autovacuum_enabled,
COALESCE(last_vacuum::text, ''),
COALESCE(last_autovacuum::text, '')
FROM calc
WHERE total_bytes >= $1
AND (
COALESCE(dead_ratio, 0) >= $2
OR (n_dead_tup >= estimated_trigger AND maintenance_age_seconds >= $3)
)
ORDER BY total_bytes DESC`
rows, err := pool.Query(
ctx, query,
p.MinTableBytes,
p.DeadTupleRatio,
p.AutovacuumLag.Seconds(),
)
if err != nil {
return nil, err
}
defer finishRows(rows, &err)
for rows.Next() {
var relation, lastVacuum, lastAutovacuum string
var totalBytes, live, dead int64
var deadRatio, trigger, maintenanceAge float64
var triggerExceeded, enabled bool
if scanErr := rows.Scan(
&relation, &totalBytes, &live, &dead, &deadRatio, &trigger,
&triggerExceeded, &maintenanceAge, &enabled, &lastVacuum, &lastAutovacuum,
); scanErr != nil {
return nil, scanErr
}
severity := "warning"
if triggerExceeded && maintenanceAge >= p.AutovacuumLag.Seconds() {
severity = "critical"
}
if !enabled {
severity = "critical"
}
findings = append(findings, Finding{
Check: "table_vacuum_debt",
Severity: severity,
Object: relation,
Message: "table exceeds the configured dead-tuple or autovacuum-lag policy; n_dead_tup and reltuples are estimates",
Data: map[string]any{
"total_bytes": totalBytes, "n_live_tup": live, "n_dead_tup": dead,
"dead_ratio": deadRatio, "estimated_trigger": trigger,
"trigger_exceeded": triggerExceeded,
"maintenance_age_seconds": maintenanceAge,
"autovacuum_enabled": enabled,
"last_vacuum": lastVacuum, "last_autovacuum": lastAutovacuum,
},
})
}
return findings, nil
}
func checkWraparound(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
const databaseQuery = `
WITH cfg AS (
SELECT
current_setting('autovacuum_freeze_max_age')::float8 AS xid_trigger,
current_setting('autovacuum_multixact_freeze_max_age')::float8 AS mxid_trigger
)
SELECT
d.datname,
age(d.datfrozenxid)::bigint AS xid_age,
cfg.xid_trigger,
age(d.datfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) AS xid_ratio,
mxid_age(d.datminmxid)::bigint AS mxid_age,
cfg.mxid_trigger,
mxid_age(d.datminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) AS mxid_ratio
FROM pg_database AS d
CROSS JOIN cfg
WHERE age(d.datfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) >= $1
OR mxid_age(d.datminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) >= $2
ORDER BY GREATEST(
age(d.datfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0),
mxid_age(d.datminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0)
) DESC`
rows, err := pool.Query(ctx, databaseQuery, p.XIDTriggerRatio, p.MXIDTriggerRatio)
if err != nil {
return nil, err
}
for rows.Next() {
var database string
var xidAge, mxidAge int64
var xidTrigger, xidRatio, mxidTrigger, mxidRatio float64
if scanErr := rows.Scan(
&database, &xidAge, &xidTrigger, &xidRatio,
&mxidAge, &mxidTrigger, &mxidRatio,
); scanErr != nil {
rows.Close()
return nil, scanErr
}
findings = append(findings, Finding{
Check: "wraparound",
Severity: "critical",
Object: "database=" + database,
Message: "database XID or MultiXact age exceeds the configured fraction of the autovacuum trigger",
Data: map[string]any{
"xid_age": xidAge, "xid_trigger": xidTrigger, "xid_ratio": xidRatio,
"multixact_age": mxidAge, "multixact_trigger": mxidTrigger,
"multixact_ratio": mxidRatio,
},
})
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
const relationQuery = `
WITH cfg AS (
SELECT
current_setting('autovacuum_freeze_max_age')::float8 AS xid_trigger,
current_setting('autovacuum_multixact_freeze_max_age')::float8 AS mxid_trigger
)
SELECT
c.oid::regclass::text AS relation,
pg_total_relation_size(c.oid)::bigint AS total_bytes,
age(c.relfrozenxid)::bigint AS xid_age,
age(c.relfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) AS xid_ratio,
mxid_age(c.relminmxid)::bigint AS mxid_age,
mxid_age(c.relminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) AS mxid_ratio
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
CROSS JOIN cfg
WHERE c.relkind IN ('r', 'm', 't')
AND (
age(c.relfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0) >= $1
OR mxid_age(c.relminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0) >= $2
)
ORDER BY GREATEST(
age(c.relfrozenxid)::float8 / NULLIF(cfg.xid_trigger, 0),
mxid_age(c.relminmxid)::float8 / NULLIF(cfg.mxid_trigger, 0)
) DESC
LIMIT 50`
rows, err = pool.Query(ctx, relationQuery, p.XIDTriggerRatio, p.MXIDTriggerRatio)
if err != nil {
return findings, err
}
defer finishRows(rows, &err)
for rows.Next() {
var relation string
var totalBytes, xidAge, mxidAge int64
var xidRatio, mxidRatio float64
if scanErr := rows.Scan(&relation, &totalBytes, &xidAge, &xidRatio, &mxidAge, &mxidRatio); scanErr != nil {
return findings, scanErr
}
findings = append(findings, Finding{
Check: "wraparound",
Severity: "critical",
Object: relation,
Message: "relation freeze horizon exceeds the configured policy",
Data: map[string]any{
"total_bytes": totalBytes, "xid_age": xidAge,
"xid_ratio": xidRatio, "multixact_age": mxidAge,
"multixact_ratio": mxidRatio,
},
})
}
return findings, nil
}
func checkReplicationSlots(ctx context.Context, pool *pgxpool.Pool, p Policy) (findings []Finding, err error) {
const query = `
WITH cfg AS (
SELECT
CASE WHEN pg_is_in_recovery()
THEN pg_last_wal_replay_lsn()
ELSE pg_current_wal_lsn()
END AS current_lsn,
current_setting('autovacuum_freeze_max_age')::float8 AS xid_trigger
), slots AS (
SELECT
s.slot_name,
s.slot_type,
s.active,
COALESCE(pg_wal_lsn_diff(cfg.current_lsn, s.restart_lsn), 0)::float8 AS retained_wal_bytes,
COALESCE(age(s.xmin), 0)::bigint AS xmin_age,
COALESCE(age(s.catalog_xmin), 0)::bigint AS catalog_xmin_age,
COALESCE(age(s.xmin), 0)::float8 / NULLIF(cfg.xid_trigger, 0) AS xmin_ratio,
COALESCE(age(s.catalog_xmin), 0)::float8 / NULLIF(cfg.xid_trigger, 0) AS catalog_xmin_ratio
FROM pg_replication_slots AS s
CROSS JOIN cfg
)
SELECT
slot_name, slot_type, active, retained_wal_bytes,
xmin_age, catalog_xmin_age, xmin_ratio, catalog_xmin_ratio
FROM slots
WHERE retained_wal_bytes >= $1
OR xmin_ratio >= $2
OR catalog_xmin_ratio >= $2
ORDER BY retained_wal_bytes DESC`
rows, err := pool.Query(ctx, query, p.SlotRetainedWALBytes, p.XIDTriggerRatio)
if err != nil {
return nil, err
}
defer finishRows(rows, &err)
for rows.Next() {
var name, slotType string
var active bool
var retained float64
var xminAge, catalogXminAge int64
var xminRatio, catalogRatio float64
if scanErr := rows.Scan(
&name, &slotType, &active, &retained,
&xminAge, &catalogXminAge, &xminRatio, &catalogRatio,
); scanErr != nil {
return nil, scanErr
}
findings = append(findings, Finding{
Check: "replication_slots",
Severity: "critical",
Object: name,
Message: "slot exceeds a WAL-retention or tuple-horizon policy; diagnose these dimensions separately",
Data: map[string]any{
"slot_type": slotType, "active": active,
"retained_wal_bytes": retained,
"xmin_age": xminAge, "catalog_xmin_age": catalogXminAge,
"xmin_ratio": xminRatio, "catalog_xmin_ratio": catalogRatio,
},
})
}
return findings, nil
}
func finishRows(rows pgx.Rows, errp *error) {
rows.Close()
if rowsErr := rows.Err(); rowsErr != nil && *errp == nil {
*errp = rowsErr
}
}
func classifyError(check string, err error) CheckError {
result := CheckError{Check: check, Message: errorMessage(err)}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
result.SQLState = pgErr.Code
}
return result
}
func errorMessage(err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return fmt.Sprintf("%s (SQLSTATE %s)", pgErr.Message, pgErr.Code)
}
return err.Error()
}
func loadPolicy() (Policy, error) {
var p Policy
var err error
if p.CheckInterval, err = requiredDuration("CHECK_INTERVAL"); err != nil {
return p, err
}
if p.QueryTimeout, err = requiredDuration("QUERY_TIMEOUT"); err != nil {
return p, err
}
if p.LongTxThreshold, err = requiredDuration("LONG_TX_THRESHOLD"); err != nil {
return p, err
}
if p.IdleInTxThreshold, err = requiredDuration("IDLE_IN_TX_THRESHOLD"); err != nil {
return p, err
}
if p.AutovacuumLag, err = requiredDuration("AUTOVACUUM_LAG_THRESHOLD"); err != nil {
return p, err
}
if p.DeadTupleRatio, err = requiredRatio("DEAD_TUPLE_RATIO"); err != nil {
return p, err
}
if p.MinTableBytes, err = requiredInt64("MIN_TABLE_BYTES"); err != nil {
return p, err
}
if p.XIDTriggerRatio, err = requiredRatio("XID_TRIGGER_RATIO"); err != nil {
return p, err
}
if p.MXIDTriggerRatio, err = requiredRatio("MXID_TRIGGER_RATIO"); err != nil {
return p, err
}
if p.SlotRetainedWALBytes, err = requiredInt64("SLOT_RETAINED_WAL_BYTES"); err != nil {
return p, err
}
maxChecks, err := requiredInt("MAX_CONCURRENT_CHECKS")
if err != nil {
return p, err
}
maxConns, err := requiredInt("DB_MAX_CONNS")
if err != nil {
return p, err
}
if maxChecks < 1 || maxConns < 1 || maxConns > int(^uint32(0)>>1) {
return p, fmt.Errorf("MAX_CONCURRENT_CHECKS and DB_MAX_CONNS must be positive and DB_MAX_CONNS must fit int32")
}
if maxConns < maxChecks {
return p, fmt.Errorf("DB_MAX_CONNS must be >= MAX_CONCURRENT_CHECKS")
}
p.MaxConcurrentChecks = maxChecks
p.DBMaxConns = int32(maxConns)
return p, nil
}
func requiredDuration(name string) (time.Duration, error) {
value, err := requiredEnv(name)
if err != nil {
return 0, err
}
d, err := time.ParseDuration(value)
if err != nil || d <= 0 {
return 0, fmt.Errorf("%s must be a positive Go duration: %q", name, value)
}
return d, nil
}
func requiredRatio(name string) (float64, error) {
value, err := requiredEnv(name)
if err != nil {
return 0, err
}
ratio, err := strconv.ParseFloat(value, 64)
if err != nil || ratio <= 0 || ratio > 1 {
return 0, fmt.Errorf("%s must be in (0,1]: %q", name, value)
}
return ratio, nil
}
func requiredInt64(name string) (int64, error) {
value, err := requiredEnv(name)
if err != nil {
return 0, err
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n < 0 {
return 0, fmt.Errorf("%s must be a non-negative base-10 integer: %q", name, value)
}
return n, nil
}
func requiredInt(name string) (int, error) {
value, err := requiredEnv(name)
if err != nil {
return 0, err
}
n, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("%s must be an integer: %q", name, value)
}
return n, nil
}
func requiredEnv(name string) (string, error) {
value := os.Getenv(name)
if value == "" {
return "", fmt.Errorf("%s is required", name)
}
return value, nil
}
初始化依赖时使用当前稳定的 pgx/v5,不在源码中锁死补丁版本:
go mod init example.com/vacuum-health
go get github.com/jackc/pgx/v5
go run .
下面只是本地实验的 policy 示例,不是生产建议值:
export DATABASE_URL='postgres://monitor:[email protected]:5432/app?sslmode=require'
export CHECK_INTERVAL='30s'
export QUERY_TIMEOUT='5s'
export LONG_TX_THRESHOLD='2m'
export IDLE_IN_TX_THRESHOLD='30s'
export AUTOVACUUM_LAG_THRESHOLD='10m'
export DEAD_TUPLE_RATIO='0.10'
export MIN_TABLE_BYTES='1073741824'
export XID_TRIGGER_RATIO='0.70'
export MXID_TRIGGER_RATIO='0.70'
export SLOT_RETAINED_WAL_BYTES='10737418240'
export MAX_CONCURRENT_CHECKS='3'
export DB_MAX_CONNS='4'
生产 policy 应来自以下约束:
- 长事务阈值:业务正常事务 P99、批处理设计上限和
idle_in_transaction_session_timeout; - Dead Tuple:表的变更速率、行宽、索引数、可接受 bloat 窗口与清理吞吐;
- Autovacuum lag:不是“距离上次 vacuum 的固定时间”,而是表已达到触发条件后允许排队多久;
- XID/MXID:以触发阈值比例 + 当前每秒消耗斜率计算 time-to-trigger/time-to-stop;
- Slot WAL:
pg_wal、归档、对象存储和恢复容量; - 表大小过滤:防止大量小表噪声,但小表环绕风险仍由独立检查覆盖。
监控角色至少需要读取相关统计视图;通常可授予 pg_monitor,再按组织安全模型补充最小权限。不要给健康检查器超级用户、终止后端或修改 Slot 的权限。
16. 生产 Runbook
16.1 第一步:确认告警对象和时间线
先回答五个问题:
- 告警来自哪个 Cluster、Primary/Standby、Database、Schema、Relation/TOAST/Index?
- 何时开始增长?是否紧邻发布、批处理、迁移、CDC 中断、只读报表或流量峰值?
- 是文件大小大、增长斜率异常、查询变慢、XID 年龄上升,还是同时发生?
- 统计是否刚 reset、实例是否刚重启、Relation 是否刚重写?
- 当前 SLO、磁盘余量、WAL/归档余量、Replica lag 和预计耗尽时间是多少?
SELECT now(), version(), pg_is_in_recovery();
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
SELECT pg_postmaster_start_time();
不要把 n_dead_tup 的一次跳变直接判定为 bloat。先对齐统计 reset、发布和流量时间线。
16.2 第二步:看维护债务与增长速率
SELECT
relid::regclass AS relation,
pg_total_relation_size(relid) AS total_bytes,
n_live_tup,
n_dead_tup,
n_ins_since_vacuum,
n_mod_since_analyze,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 100;
重要字段:
total_bytes:Heap、TOAST、Index 合计;必须再拆分定位;n_dead_tup:估算,不是可直接清理的精确数量;n_ins_since_vacuum:append-only 表是否需要 insert-trigger vacuum/VM/freeze;n_mod_since_analyze:Planner 统计是否落后;last_*和 count:辨别从未运行、运行过但无效、还是运行频率不足。
将这些值作为时间序列采样,计算:
垃圾生成速率 = Δn_dead_tup 或业务 UPDATE/DELETE/s
清理速率 = 每次 vacuum 前后变化 / 持续时间
空间增长速率 = Δpg_total_relation_size / Δt
耗尽时间 = 剩余磁盘 / 净增长速率
16.3 第三步:查看正在运行或排队的维护
SELECT
a.pid, a.backend_type, a.state,
a.wait_event_type, a.wait_event,
a.query_start,
clock_timestamp() - a.query_start AS runtime,
p.relid::regclass AS relation,
p.phase,
p.heap_blks_total,
p.heap_blks_scanned,
p.heap_blks_vacuumed,
p.index_vacuum_count,
p.dead_tuple_bytes,
p.num_dead_item_ids,
p.delay_time
FROM pg_stat_activity AS a
LEFT JOIN pg_stat_progress_vacuum AS p ON p.pid = a.pid
WHERE a.backend_type = 'autovacuum worker'
OR p.pid IS NOT NULL
ORDER BY a.query_start;
判断:
scanning heap是否持续前进;index_vacuum_count是否多轮增长,提示 dead TID 很多或内存/索引成本高;delay_time是否主要由 cost 节流造成 [PG18];wait_event是 Relation Lock、Buffer Pin、I/O 还是无等待但吞吐低;- 任务是否为
to prevent wraparound,这种 Worker 不应随意取消。
同时检查日志中的 Autovacuum duration、pages、tuples、index scans、WAL 和 I/O;log_autovacuum_min_duration 应按观测策略设置,而不是永久只记录极慢任务。
16.4 第四步:找 blocker 与逻辑 horizon blocker
锁 blocker:
SELECT
blocked.pid AS blocked_pid,
blocked.wait_event_type,
blocked.wait_event,
blocker.pid AS blocker_pid,
blocker.state AS blocker_state,
clock_timestamp() - blocker.xact_start AS blocker_xact_age,
left(blocked.query, 160) AS blocked_query,
left(blocker.query, 160) AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid)
JOIN pg_stat_activity AS blocker ON blocker.pid = b.pid
ORDER BY blocker.xact_start;
逻辑地平线 blocker:
SELECT
pid, usename, application_name, client_addr,
state, xact_start,
clock_timestamp() - xact_start AS xact_age,
backend_xmin, age(backend_xmin) AS xmin_age,
wait_event_type, wait_event,
left(query, 200)
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL OR xact_start IS NOT NULL
ORDER BY backend_xmin NULLS LAST, xact_start;
SELECT * FROM pg_prepared_xacts ORDER BY prepared;
WITH wal_position AS (
SELECT CASE
WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
ELSE pg_current_wal_lsn()
END AS current_lsn
)
SELECT
s.slot_name, s.slot_type, s.active,
s.restart_lsn,
COALESCE(pg_wal_lsn_diff(w.current_lsn, s.restart_lsn), 0) AS retained_wal_bytes,
s.xmin, age(s.xmin), s.catalog_xmin, age(s.catalog_xmin)
FROM pg_replication_slots AS s
CROSS JOIN wal_position AS w;
WITH wal_position AS (
SELECT CASE
WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()
ELSE pg_current_wal_lsn()
END AS current_lsn
)
SELECT
r.pid, r.application_name, r.state, r.backend_xmin,
pg_wal_lsn_diff(w.current_lsn, r.replay_lsn) AS replay_bytes
FROM pg_stat_replication AS r
CROSS JOIN wal_position AS w;
pg_stat_replication.backend_xmin 是 Standby 通过 hot standby feedback 报告的 horizon。若它很老,继续在主库加 Worker 并不能解决不可清理问题。
16.5 第五步:找到最早出现的执行计划估算错误
维护告警常与统计陈旧同时出现。流程:
- 从 APM/
pg_stat_statements找到延迟、shared blocks 或 temp/WAL 在故障起点首次偏离的queryid; - 比对发布前后规范化 SQL、参数分布和 Plan;
- 检查目标表的
n_mod_since_analyze、last_autoanalyze、列 statistics target 与扩展统计; - 在可控环境执行
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY),逐节点比较 estimated rows 与 actual rows,找到第一个数量级偏差,而不是只看最上层总耗时; ANALYZE后重测。若估算修复但 I/O 仍高,问题更可能是物理 bloat/VM,而不是 Planner。
SELECT
queryid, calls, total_exec_time, mean_exec_time,
rows, shared_blks_hit, shared_blks_read,
temp_blks_read, temp_blks_written,
wal_records, wal_fpi, wal_bytes,
left(query, 240) AS sample
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 30;
pg_stat_statements 是扩展,字段随版本/配置不同;不存在时使用 APM、日志和采样 Explain。对写语句运行 EXPLAIN ANALYZE 会真正执行,生产上优先在克隆数据或安全事务中验证,并考虑不可回滚副作用。
16.6 第六步:区分 CPU、内存、I/O、锁、连接池、WAL、Vacuum 与复制
| 症状 | 证据 | 进一步确认 | 常见误判 |
|---|---|---|---|
| CPU 饱和 | OS CPU、run queue;VACUUM/前台均 runnable | pg_stat_activity、perf/采样、索引数量、Tuple 判断量 | 只看低 I/O 就认定无压力 |
| 内存压力 | RSS、swap/OOM、Worker 并发 | maintenance_work_mem × 并发维护、shared buffers、OS cache | 只看单 Worker 内存 |
| I/O 饱和 | device latency/queue;DataFileRead/Write | pg_stat_io、Buffers、Checkpoint、AIO 队列 | 把所有 I/O 都归因于 VACUUM |
| 锁问题 | wait_event_type='Lock'、blocking PIDs | pg_locks 模式与队列;是否 FULL/DDL | 终止 Autovacuum 而忽略前方 DDL |
| 连接池问题 | acquire duration、waiters、acquired=max | 活跃 SQL 数、事务年龄、goroutine 队列 | 增加连接掩盖长事务 |
| WAL 问题 | WAL/s、wal_fpi、归档失败 | pg_stat_wal、Checkpoint、Slot retained WAL | 只扩 pg_wal 不修消费者 |
| Vacuum 问题 | 触发后长时间未运行/清理速率不足 | progress、日志、每表 reloptions、Worker 队列 | 把 n_dead_tup=0 当作无 bloat |
| 复制问题 | replay lag、recovery conflict、feedback xmin | pg_stat_replication、pg_stat_database_conflicts、Slot | 只看时间 lag,不看 bytes/LSN |
PG18 示例:
SELECT * FROM pg_stat_io;
SELECT * FROM pg_stat_wal;
SELECT * FROM pg_stat_checkpointer;
SELECT * FROM pg_stat_database_conflicts;
这些累计视图必须结合 stats_reset 和采样间隔求速率。
16.7 第七步:先确认环绕优先级
SELECT
datname,
age(datfrozenxid) AS xid_age,
mxid_age(datminmxid) AS mxid_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
再列出最老 Relation。若 XID/MXID time-to-trigger 很短,优先级高于“把索引压小”或“优化平均延迟”。先找 horizon blocker,再让 anti-wrap VACUUM 推进。不得通过调高 freeze max age 来隐藏告警;这只是在透支安全余量。
16.8 第八步:可在线执行与高风险操作分级
| 级别 | 操作 | 条件 |
|---|---|---|
| 通常在线 | ANALYZE;普通 VACUUM;查询统计视图;调整未来每表 Autovacuum reloptions | 仍需监控 I/O/WAL;先设合理 statement/lock policy |
| 在线但需审批 | REINDEX CONCURRENTLY;取消明显失控的查询;降低写入准入;临时调整 Worker/cost | 评估额外空间、旧快照、SQLSTATE、业务重试和副本 |
| 高风险 | VACUUM FULL、普通 REINDEX、CLUSTER、pg_repack、终止业务事务、COMMIT/ROLLBACK PREPARED | 维护窗、负责人、容量、锁/WAL/复制/回滚计划 |
| 极高风险 | 删除/推进 Slot、关闭 Autovacuum、提高环绕阈值掩盖风险、直接修改系统目录 | 需要明确数据丢失/CDC 重建/停机恢复方案;通常禁止 |
16.9 第九步:临时止损
按根因选择,不要一次执行所有动作:
- 垃圾生成过快:对批处理/API 启用 admission control,降低并发或暂停非关键写入;不要让客户端无限重试。
- 长事务/idle:联系 owner;确认幂等和回滚影响后,优先让应用正常提交/回滚,再考虑
pg_cancel_backend/pg_terminate_backend。 - Prepared Transaction:由事务协调器决议;若协调器丢失,按业务账本人工判定,不能猜。
- Slot:恢复消费者;确需删除/重建时先确认 CDC 重放起点、全量同步和可接受数据缺口。
- 环绕紧急:清除最老 horizon 后,对最老 Relation 执行普通 VACUUM;必要时暂时
INDEX_CLEANUP OFF, TRUNCATE OFF优先推进冻结下界,之后补做索引治理。 - 前台 SLO 被维护冲击:调整单次维护成本/Worker 数、错峰和存储准入;不是简单停止所有 Autovacuum。
- Standby 冲突:缩短报表事务、路由到专用延迟副本,或有界开启 feedback;同步观察主库 bloat。
- 磁盘临界:先阻止净增长并清理安全可删的 WAL/临时文件;不要手工删除
pg_wal或 Relation 文件。
16.10 第十步:根本修复
- 按表设置 threshold/scale/insert/analyze 策略,[PG18] 用 max threshold 控制超大表上界;
- 在 I/O、CPU、内存和副本容量允许下调整 Worker slots/max workers、cost delay/limit 与维护并发;
- 缩短应用事务,设置有依据的 idle/transaction/statement timeout,把 RPC 移出事务;
- 减少无价值索引,提升 HOT 比例,按实际行增长设置 fillfactor;
- 对 append-only 分区在封存后执行 ANALYZE/FREEZE,并用分区 DROP 替代巨量 DELETE;
- 为 Slot/CDC 定义 owner、最大离线时间、WAL 容量和重建流程;
- 对报表副本设置查询时限、反馈策略和独立 SLO;
- 对已形成的病理性 Heap/Index bloat 按决策表选择 REINDEX、FULL、repack 或分区迁移;
- 把维护 WAL 纳入
max_wal_size、归档带宽、对象存储和副本容量规划。
16.11 第十一步:验证修复
至少连续跨越多个 Autovacuum 周期确认:
- 触发后 Worker 能在 policy 窗口内开始并完成;
- XID/MXID age 和 time-to-limit 下降或保持安全斜率;
n_dead_tup/实际 bloat 进入稳定波动,不再单调增长;- Heap/Index size 的净增长符合业务数据增长;
- 核心查询的估算、Buffers、Heap Fetches、P95/P99 恢复;
- WAL/归档、Checkpoint 和 Replica lag 无持续恶化;
- 没有 invalid index、残留 repack 对象或异常 Slot;
- 备份、PITR 与恢复校验仍通过。
16.12 第十二步:监控与告警
告警应覆盖“水平、斜率和剩余时间”:
- 每表 dead/live ratio、dead tuple 生成速率、触发阈值超限持续时间;
- 最后一次成功 vacuum/analyze 与累计 duration [PG18];
- VACUUM phase 停滞、扫描速度、index vacuum cycles、cost delay;
- 最老事务、最老
backend_xmin、idle in transaction、Prepared Transaction; - database/relation XID/MXID age、XID/s、time-to-trigger/time-to-stop;
- Slot retained WAL、
xmin/catalog_xmin年龄分别告警; - Relation/Index/TOAST size 与磁盘/WAL/归档耗尽预测;
- Index Only Scan Heap Fetches、核心计划估算偏差;
- Autovacuum Worker 使用率/排队、CPU/I/O/Checkpoint;
- 复制 byte/time lag、recovery conflicts、feedback horizon;
- pgxpool acquire duration、acquired/max、取消/超时率和应用队列长度。
17. 常见错误与反模式
| # | 反模式 | 为什么错 | 正确做法 |
|---|---|---|---|
| 1 | 全局关闭 Autovacuum 来消除抖动 | 把可控后台成本换成无界 bloat、统计陈旧和环绕停写 | 找出具体表/阶段/I/O 根因,做每表调度、节流和容量治理 |
| 2 | 十亿行表沿用默认 scale factor | 触发需要的变更量可能远超 SLO | 按变更速率和垃圾窗口推导 threshold/scale,[PG18] 使用 max cap |
| 3 | 把 n_dead_tup 当精确事实 | 它是估算且受统计刷新/reset 影响 | 看趋势,结合 VACUUM 日志、pgstattuple_approx、size 与 Buffers |
| 4 | 每晚对所有表 VACUUM FULL | 强锁、重写、额外磁盘与 WAL 会制造维护事故 | 日常普通 VACUUM;只对确认的病理性 bloat 选择重写 |
| 5 | 认为普通 VACUUM 后文件必须变小 | 中间空页只能内部复用,通常不能返还 OS | 区分内部复用、尾部截断和完整重写 |
| 6 | 看到 Autovacuum 慢就终止 Worker | 可能是 anti-wraparound;取消只让年龄更危险 | 先看 query 文本、age、progress 和 blocker;优先清除 horizon |
| 7 | 调高 freeze max age 来“解决”告警 | 只是缩短最终安全余量,根因仍在 | 修复长事务/Slot/吞吐,尽早推进 Freeze |
| 8 | 把 Slot 的 restart_lsn 与 xmin 混成一个告警 | WAL 磁盘和 Tuple 清理是不同故障路径 | 分别监控 retained WAL、xmin、catalog_xmin 和 owner |
| 9 | 无期限开启 hot_standby_feedback | 副本长查询可把主库 bloat 放大 | 给查询和 feedback 定边界,必要时使用专用延迟副本 |
| 10 | 事务内调用慢 RPC/等待用户 | 固定 Snapshot、锁和连接,推迟 VACUUM | 把外部工作移出事务,使用 Outbox/Saga/可靠协调器 |
| 11 | 把 autovacuum_work_mem 设很大而不乘 Worker 数 | 并发 Worker 可造成总内存峰值和 OOM | 按并发维护上限做总量预算,观察实际 dead TID cycles |
| 12 | 盲目增加 Worker 和 cost limit | I/O/WAL/副本可能先饱和,前台 P99 更差 | 从净清理能力、设备队列、归档与复制容量联合调参 |
| 13 | 用 REINDEX TABLE 解决 Heap bloat | 它只重建索引 | 先拆分 Heap/Index/TOAST 大小,再选择对应工具 |
| 14 | 对超大表高峰执行精确 pgstattuple | 全 Relation 扫描会争用 I/O 和缓存 | 先用统计/近似扫描,低峰或克隆环境做精确诊断 |
| 15 | 把 pg_repack 当“零锁、零风险” | 有短 AEL、长 SUE、额外盘、触发器、WAL 与失败残留 | dry-run、--no-kill-backend、容量/CDC/锁/恢复预演 |
| 16 | 只监控普通 XID,不监控 MultiXact | 热点 Tuple Lock/外键可先耗尽 MultiXact | 同时监控 mxid_age(datminmxid/relminmxid) 与成员增长 |
| 17 | 只看平均延迟和 TPS | VACUUM/Checkpoint 往往首先破坏 P99 | 记录 P50/P95/P99、设备时延、Wait、WAL 与 lag |
| 18 | 分区叶子自动分析,就假定父表统计总是新 | 父表统计可能不会按预期自动更新 | 为分区父表安排显式 ANALYZE,并核对 PG18 继承范围变化 |
18. 模拟生产事故案例
18.1 模拟生产案例一:大表默认阈值 + 长事务导致 P99 持续恶化
系统背景
订单主表约十亿行、12 个索引,峰值每秒数万次状态更新。Autovacuum 使用接近默认比例阈值;报表服务每天开启 Repeatable Read 事务,把查询结果分页导出到对象存储,事务内还会调用外部 API。
故障现象
- 主表与多个索引每小时稳定增长,业务净数据量却基本不变;
- 查询仍使用 Index Only Scan,但
Heap Fetches从很低上升到大量; - 前台平均延迟变化不大,P99 和存储读时延周期性尖峰;
- Autovacuum 日志显示任务运行过,团队因此认为“Vacuum 正常”;
- 副本 lag 在维护窗口扩大。
错误假设
“Worker 数太少,直接从 3 提到 20 就能解决。”
排查过程
- 计算阈值发现比例项对应上亿 Dead Tuple,触发本身太晚;
pg_stat_activity发现报表会话idle in transaction数小时,backend_xmin最老;- VACUUM VERBOSE 显示大量版本不可移除;Worker 虽完成扫描,却没有清掉关键旧版本;
n_tup_hot_upd/n_tup_upd很低,因为状态列被多个索引覆盖;EXPLAIN (ANALYZE, BUFFERS)显示 Index Only Scan 的 Heap Fetches 和 shared reads 增长;pg_stat_wal、归档和pg_stat_replication显示维护 WAL 把副本拖慢。
根因
- 大表只依赖默认 scale factor,维护启动太晚;
- 报表事务跨越外部 I/O,固定 Snapshot,旧版本长期为 Recently Dead;
- 冗余索引降低 HOT 比例并扩大每次更新/清理成本;
- 监控只看“最后一次 Autovacuum 时间”,没有看清理效果和 horizon。
临时止损
- 与报表 owner 确认后取消并结束旧事务,将导出改为分段短事务;
- 对写入做 admission control,降低垃圾生成峰值;
- 先普通 VACUUM 关键分区/表,并监控 WAL/副本;
- 暂不启动 FULL 或大量并发 REINDEX,避免磁盘和复制雪崩。
最终修复
- 按每秒更新速率与目标垃圾窗口设置每表 threshold/scale,并在 PG18 设置 max threshold;
- 报表改为游标/分块读取,每块独立事务,外部上传不在事务内;
- 删除重复/低价值索引,调整 fillfactor 并验证 HOT 比例;
- 只对确认严重的索引逐个
REINDEX CONCURRENTLY;若 Heap 必须缩小,在容量验证后使用分区迁移或在线 repack。
监控补充
增加最老 backend_xmin、触发阈值超限持续时间、VACUUM 前后 dead 清理量、Heap Fetches、size slope、WAL/s、Replica byte lag 和 pgxpool acquire P99。
防止复发
把事务时长 SLO、每表 Autovacuum policy 和索引变更成本纳入 Schema review;任何新增报表必须通过“事务内不得调用慢外部服务”的检查。
18.2 模拟生产案例二:反馈地平线与离线 CDC 同时制造主库双重磁盘压力
系统背景
Primary 有两个物理只读副本和一个逻辑 CDC Slot。分析副本开启 hot_standby_feedback,允许小时级查询;CDC 消费者因证书过期离线。团队只监控 Replica time lag,没有分别监控 Slot WAL 和 Tuple horizon。
故障现象
pg_wal目录快速增长,归档队列落后;- 主库若干高更新表也持续膨胀;
- 分析副本几乎不再取消长查询,看起来“读体验变好”;
- Autovacuum 扫描 I/O 很高,清理效果却差;
- 磁盘剩余空间进入小时级耗尽预测,故障切换演练被迫停止。
错误假设
“Replication Slot 只保留 WAL,扩容 pg_wal 磁盘就行;hot_standby_feedback 只影响副本。”
排查过程
pg_replication_slots显示逻辑 Slot 的restart_lsn落后,解释 WAL 保留;同时catalog_xmin很老,解释系统目录清理受阻;pg_stat_replication.backend_xmin显示分析副本反馈的 xmin 更老,主库业务表旧版本因此不能回收;pg_stat_database_conflicts低并不代表健康,只说明 feedback 把“查询取消”成本转移成主库 bloat;- 长查询结束后,普通 VACUUM 才能清理 Heap;CDC 恢复后
restart_lsn才推进;两条路径相互独立; - 估算 FULL/repack 所需临时空间后发现当前磁盘根本无法安全重写。
根因
- CDC 没有最大离线时间、证书到期告警和 Slot 重建 Runbook;
- 分析副本允许无界长查询并永久 feedback;
- 告警把
restart_lsn、catalog_xmin和 feedbackbackend_xmin混为“复制 lag”; - 团队试图用扩盘代替生命周期治理。
临时止损
- 修复证书并恢复 CDC;若无法恢复,按业务批准进行全量重建后再删除旧 Slot,明确接受位点变化;
- 终止/缩短分析副本的失控查询,使 feedback horizon 前进;
- 对非关键写入限流,保护磁盘和归档;
- WAL 可回收、horizon 释放后执行普通 VACUUM,暂不做空间峰值过高的重写。
最终修复
- Slot 配置 owner、retained WAL 上限、
catalog_xminage、消费者心跳和自动升级证书; - 报表迁移到专用延迟副本,设置查询时限和有界 feedback 策略;
- 分别建立 WAL-retention、Tuple-retention、replay lag 和 recovery conflict 仪表盘;
- 为磁盘、归档和 CDC 全量重建预留容量与演练窗口。
监控补充
告警 restart_lsn bytes、xmin/catalog_xmin age、pg_stat_replication.backend_xmin、最老 Standby query、主表 bloat slope、归档失败和 time-to-disk-full。
防止复发
任何新 Slot 或 feedback 变更必须绑定 owner、SLO、失联处置和容量预算;灾备演练必须包含“消费者离线 + 长报表查询”的组合故障,而不是只测试进程宕机。
19. 面试题
19.1 核心概念题(5 题)
题 1:Dead Tuple 与 Recently Dead 有什么区别?
- 30 秒回答:Recently Dead 已被更新/删除,但仍可能被某个旧 Snapshot 看见,所以 VACUUM 不能移除;Dead 已越过全局可见性地平线,不再可能被任何相关快照看到,可以安全清理。
- 深入回答:状态由删除/更新事务是否提交及 OldestXmin/Global Visibility 决定,不是按墙钟时间。长 Repeatable Read、Prepared Transaction、Slot xmin 或 feedback horizon 都会延长 Recently Dead。Page pruning/VACUUM 只有在确认 Dead 后才释放 Tuple;索引项还需 Index Cleanup。
- 面试官真正考察:是否把 MVCC 可见性与物理回收联系起来。
- 常见错误回答:“DELETE 后就是 Dead;VACUUM 立刻删掉。”
- 追问:为什么一个没有锁等待的长事务也能导致 bloat?
- 追问答案:它保留旧 Snapshot/
backend_xmin,使旧版本仍有潜在观察者;VACUUM 不等待锁,却只能把它们留作 Recently Dead。
题 2:普通 VACUUM 和 VACUUM FULL 的本质差异是什么?
- 30 秒回答:普通 VACUUM 在原 Relation 内清理并让空间复用,允许正常 DML,通常不缩文件;FULL 重写表和索引,取得
ACCESS EXCLUSIVE,能紧凑并返还空间,但阻塞、耗盘、耗 WAL。 - 深入回答:普通 VACUUM 稳定 Block Number,只能截断表尾连续空页;FULL 创建新物理文件复制存活行并交换 relfilenode。FULL 适用于已确认的病理性 bloat 和维护窗,不是日常维护。在线替代有 pg_repack/分区迁移,但它们也有短强锁、额外空间和复制成本。
- 面试官真正考察:能否从存储定位、锁和运维成本解释,而非只背语法。
- 常见错误回答:“FULL 只是更深度、可以随时在线跑的 VACUUM。”
- 追问:为什么
REINDEX TABLE不能替代 FULL? - 追问答案:REINDEX 只重建索引,不重写 Heap;Heap 中空页、低密度页和 TOAST bloat 仍在。
题 3:FSM 与 Visibility Map 有什么区别?
- 30 秒回答:FSM 记录页面可用空间,帮助写入找页;VM 记录 Heap Page 的 all-visible/all-frozen,帮助 VACUUM 跳页和 Index Only Scan 跳过 Heap 可见性检查。
- 深入回答:DML 会清除 VM bit,VACUUM 验证后设置;all-frozen 是 all-visible 的更强状态。FSM 值是空间估算,可能滞后;VM 的正确性关系到查询可见性,设置更严格。索引自身没有 VM。
- 面试官真正考察:是否理解辅助 Fork 对写路径、读路径和冻结的不同作用。
- 常见错误回答:“两者都是记录空页,VM 用于索引空间。”
- 追问:覆盖索引为什么仍有大量 Heap Fetches?
- 追问答案:相关 Heap Page 未 all-visible,执行器必须访问 Heap 验证 MVCC;常见原因是近期 DML、Vacuum 落后或旧 horizon。
题 4:relfrozenxid、datfrozenxid 与 Freeze 是什么关系?
- 30 秒回答:Freeze 把足够老的已提交 Tuple 标为永久可见语义;
relfrozenxid是 Relation 中无需再考虑更老普通 XID 的保守下界,datfrozenxid汇总数据库内最老下界,用于环绕治理。 - 深入回答:XID 是 32 位环形空间,普通历史 XID 不能永久参与前后比较。VACUUM 扫描并冻结后才能安全推进目录下界,再回收更老 pg_xact 状态。它们不是表中最小 xmin 的精确统计。TOAST 与物化视图也要纳入维护。
- 面试官真正考察:能否解释为什么只删除数据仍需要 Vacuum/Freeze。
- 常见错误回答:“冻结就是把表设成只读;
relfrozenxid是最后一次 Vacuum 的 XID。” - 追问:危急时为什么不优先
VACUUM FULL FREEZE? - 追问答案:FULL 需要强锁和额外磁盘,可能根本拿不到锁或耗时更长;安全目标是尽快用普通/aggressive VACUUM 推进冻结边界。
题 5:Autovacuum 的 threshold、scale factor 和 insert threshold 如何工作?
- 30 秒回答:更新/删除触发近似为 base threshold + scale factor × reltuples,[PG18] 可再由 max threshold 封顶;insert trigger 还按未 all-frozen 页面比例修正;ANALYZE 有独立公式。
- 深入回答:统计是估算且调度受 Launcher 周期、Worker、锁和成本预算影响。大表只靠比例会触发过晚,小表只靠很小比例可能过频;应按垃圾生成速率、允许窗口、单次清理吞吐和硬件推导每表值。
- 面试官真正考察:是否会从工作负载建模,而不是背默认值。
- 常见错误回答:“scale factor 越小越好;所有表设成 0.01。”
- 追问:append-only 表为什么也可能触发 Vacuum?
- 追问答案:insert-trigger Vacuum 可推进 VM/all-frozen、冻结旧行并支持 Index Only Scan;没有 Dead Tuple 不等于不需要维护。
19.2 原理与排障题(6 题)
题 6:VACUUM 已成功,为什么表文件和 n_dead_tup 仍然很大?
- 30 秒回答:文件不缩可能只是空间转为内部复用;
n_dead_tup是估算,也可能有旧 horizon 让版本不可移除,或统计尚未刷新。应拆分这三种情况。 - 深入回答:检查 VACUUM VERBOSE 的 not removable、
backend_xmin/Prepared/Slot、pgstattuple_approx、FSM/free space、Relation size 增长斜率和后续写入复用。若 Heap 已清理但文件必须返还 OS,才评估重写;若只是索引 bloat,REINDEX 更合适。 - 面试官真正考察:是否会验证维护“效果”而非只看 command success。
- 常见错误回答:“再跑一次 FULL 就一定对。”
- 追问:如何证明空间已复用?
- 追问答案:观察 VACUUM 后 free space 上升、后续等量 DML 文件增长显著变缓,同时 dead tuple 降低;必要时用
pg_freespacemap/pgstattuple验证。
题 7:如何定位长事务阻止 VACUUM?
- 30 秒回答:按
xact_start和backend_xmin排序pg_stat_activity,同时检查pg_prepared_xacts、Slotxmin/catalog_xmin和pg_stat_replication.backend_xmin;再用 VACUUM VERBOSE 证明版本不可移除。 - 深入回答:区分锁等待与可见性地平线:前者有 blockers/wait event,后者 Vacuum 可能正常扫描。联系 owner,先正常结束事务;终止前评估回滚、业务幂等和 Commit 不确定。Prepared Transaction 必须用 2PC 决议。
- 面试官真正考察:是否理解“逻辑阻塞”以及生产终止风险。
- 常见错误回答:“查询
pg_locks找不到就说明没有 blocker。” - 追问:READ COMMITTED 长事务一定固定整个事务的 Snapshot 吗?
- 追问答案:每条语句通常取新 Snapshot,影响与执行状态相关;Repeatable Read/Serializable 明确使用事务级 Snapshot。排障不能只看 isolation 名称,要看实际
backend_xmin、游标和会话状态。
题 8:Replication Slot 如何同时影响 WAL 和 VACUUM?
- 30 秒回答:
restart_lsn决定最早仍需 WAL,影响pg_wal;xmin/catalog_xmin决定普通/目录 Tuple 回收地平线,影响 Vacuum。它们必须分别监控。 - 深入回答:离线物理 Slot 可能主要保留 WAL;逻辑 Slot 常同时持有 catalog horizon,但不能只凭类型推断。恢复消费者、删除 Slot、推进位点都有数据连续性成本。扩盘只延后 WAL 故障,不解除 Tuple horizon。
- 面试官真正考察:复制与 MVCC 的边界是否准确。
- 常见错误回答:“Slot 只防止 WAL 被删除;Vacuum 与它无关。”
- 追问:删除逻辑 Slot 前要确认什么?
- 追问答案:消费者可重建起点、是否接受 CDC 缺口、全量同步时间、下游幂等、HA 上 Slot 连续性,以及删除后 WAL/目录回收的验证方案。
题 9:hot_standby_feedback 的收益和风险是什么?
- 30 秒回答:它让 Standby 报告最老 query horizon,Primary 延迟清理,从而减少 recovery conflict/查询取消;代价是主库旧版本保留和 bloat,尤其是无界长查询。
- 深入回答:关闭 feedback 时,冲突成本表现为等待重放或取消查询,受 standby delay 参数约束;开启后成本转移到 Primary 空间、缓存和 Vacuum。架构上可用专用报表副本、查询时限、最大延迟和路由隔离平衡。
- 面试官真正考察:是否能做一致性、可用性和容量取舍。
- 常见错误回答:“开启只会让副本更稳定,没有主库成本。”
- 追问:如何监控 feedback 的真实影响?
- 追问答案:看
pg_stat_replication.backend_xmin、Standby 最老查询、主表 dead/size slope、recovery conflicts 与 replay lag,并做开关/查询时限前后对比。
题 10:Autovacuum 运行时 Index Only Scan 为什么仍退化?
- 30 秒回答:运行不等于已完成,也不等于能设置 all-visible。页面有近期 DML、Recently Dead 或扫描尚未覆盖时,VM bit 未恢复,Index Only Scan 仍要 Heap Fetch。
- 深入回答:检查 progress phase/blocks、长 horizon、VM 覆盖、
relallvisible/relpages与计划中的 Heap Fetches。解决可能是缩短事务、提前触发 Vacuum、降低 churn 或调整索引/工作负载,而非强制 Planner 使用某节点。 - 面试官真正考察:能否把 VM 状态与执行器行为连接起来。
- 常见错误回答:“只要有 INCLUDE 覆盖列就绝不会访问 Heap。”
- 追问:DML 为什么必须清除 VM bit?
- 追问答案:新/改 Tuple 的可见性取决于事务提交与快照;为保证正确性,页面不能继续宣称对所有事务 all-visible,直到 VACUUM 重新验证。
题 11:index_vacuum_count 多次增长说明什么,如何处理?
- 30 秒回答:通常表示 Dead TID 存储达到预算,VACUUM 多次在 Heap 扫描与索引/Heap 清理间循环;也可能是大量 dead items。应结合 bytes、索引数、内存和 I/O 判断。
- 深入回答:[PG17+] 看
max_dead_tuple_bytes、dead_tuple_bytes、num_dead_item_ids。提高维护内存可能减少 cycles,但总内存会乘 Worker 数;减少无价值索引、让 Vacuum 更早运行或提高 HOT 可能更根本。紧急 Freeze 可暂时跳过 Index Cleanup,但会留下索引债务。 - 面试官真正考察:是否会从进度状态推导内存、索引和工作负载成本。
- 常见错误回答:“次数多就是 Vacuum 重复跑了多次,直接终止。”
- 追问:为什么无限增加
maintenance_work_mem不一定更快? - 追问答案:可能导致内存压力、OS Cache 收缩和并发 Worker 总量爆炸;瓶颈也可能是索引 I/O、WAL、CPU 或 horizon,内存不能解决。
19.3 架构设计题(4 题)
题 12:为十亿行高更新表设计 Autovacuum 策略。
- 30 秒回答:先测更新/删除速率、单次清理吞吐和允许垃圾窗口,推导每表 base/scale/[PG18] max threshold;为 ANALYZE、insert trigger、freeze、TOAST 分别定策略,并用 admission control 保证生成速率低于清理能力。
- 深入回答:控制事务时长和索引数量,提高 HOT;对时间型数据分区,封存后 Freeze;按 CPU/I/O/WAL/副本容量设置 Worker 与 cost;监控 trigger-overdue、size slope、VM、XID/MXID time-to-limit。优点是维护小而频繁、P99 平滑;缺点是持续背景成本和策略复杂。替代是批量分区替换/事件模型,但需改数据访问。
- 面试官真正考察:能否把公式、容量、事务和 HA 统一设计。
- 常见错误回答:“所有大表 scale factor 设 0,threshold 设 50。”
- 追问:如何验证策略不是过度 Vacuum?
- 追问答案:比较每次扫描/清理量、WAL/I/O、前台 P99、VM 收益和文件稳定性;若频繁扫描却几乎无可清理/冻结收益,调整 base/insert/cost,而非只看次数。
题 13:如何设计既支持小时级报表又不让主库 bloat 的只读架构?
- 30 秒回答:不要让无界报表直接通过 feedback 固定主库 horizon;使用专用延迟副本或快照化数据仓库,设查询/事务时限和资源隔离,并明确可接受的取消、陈旧度与复制延迟。
- 深入回答:选项包括:普通副本关闭/有界 feedback,让冲突取消;专用延迟副本接受更大 lag;逻辑 ETL 到分析库;定期快照/物化数据。同步副本不应承担无界报表。监控 query age、backend_xmin、conflicts、lag、主库 bloat。优点是隔离 OLTP,缺点是数据新鲜度、成本和管道复杂度。
- 面试官真正考察:CAP 式取舍、复制冲突与成本归属。
- 常见错误回答:“在所有副本开 feedback 就同时得到实时和不取消。”
- 追问:Planned Switchover 前如何处理延迟报表副本?
- 追问答案:不把明显落后/有旧 horizon 的节点直接提升;先停止报表、追平并验证 timeline/slot/age,或选择健康候选,完成 fencing 与应用重连演练。
题 14:在线修复 5 TB 表的 Heap 与 Index bloat,如何选型?
- 30 秒回答:先量化 Heap/各 Index/TOAST、可复用空间和是否真的要返还 OS;索引单独用 CONCURRENT REINDEX,Heap 重写在分区迁移、pg_repack 或维护窗 FULL 之间选,并以额外盘、WAL、锁、CDC 和副本容量为硬约束。
- 深入回答:5 TB 级别通常不能直接 FULL。若按时间分区,可建新分区并迁移/交换;非分区表可分批逻辑迁移或 pg_repack,但持续写入追赶、约 2×额外空间、短 AEL 与长 SUE 都要压测。先清理 horizon,避免把不可删版本复制进新结构。全程设置 lock timeout、暂停 DDL、监控归档/lag,并准备失败残留清理。
- 面试官真正考察:是否会把“在线”拆成锁窗口、资源峰值和失败恢复。
- 常见错误回答:“执行 pg_repack 就零停机,剩余空间等于表大小即可。”
- 追问:何时选择不重写?
- 追问答案:若空间已稳定复用、查询 Buffers/P99 可接受、磁盘容量足够且重写风险大,bloat 可能是合理稳态;优化触发策略即可。
题 15:设计企业级 Vacuum 健康与自动化平台。
- 30 秒回答:平台分采集、策略、诊断、审批执行四层;分别监控维护债务、horizon、XID/MXID、WAL/Slot、资源与 HA,阈值按表/服务 SLO 配置,自动化默认只诊断,危险动作需审批和幂等审计。
- 深入回答:采集来自统计视图、Autovacuum 日志、OS/I/O、pgxpool、复制与归档;策略计算 trigger-overdue、净清理速率和 time-to-limit;诊断关联发布/queryid/blocker;执行器支持普通 VACUUM/ANALYZE、每表 reloptions 和有界并发,FULL/repack/Slot/terminate 走变更流程。多 Primary 场景用 fencing/角色检测,避免在旧主执行。优点是提前治理,缺点是误报、权限和控制面复杂。
- 面试官真正考察:可观测性、控制面安全、SRE 与数据库知识的结合。
- 常见错误回答:“定时遍历所有表,dead_pct > 10% 就自动 FULL。”
- 追问:如何避免自动 Vacuum 形成维护风暴?
- 追问答案:全局/每节点 semaphore、I/O/WAL/lag admission、优先级队列、随机错峰、上下文取消、最大任务时长和 circuit breaker;任务需幂等并在角色切换后重新确认目标。
20. 练习与参考答案
20.1 理论题(5 题)
理论题 1
题目:一张 800 GB 表经过普通 VACUUM 后,n_dead_tup 大幅下降,但文件仍为 800 GB。能否据此断定 VACUUM 无效?
参考答案:不能。普通 VACUUM 的首要目标是让 Heap/Index 空间在 Relation 内复用,并更新 VM/FSM/冻结边界;中间空页不能直接从文件移除,只有表尾连续空页可能截断。应检查后续写入是否复用、文件净增长斜率、pgstattuple_approx free space、查询 Buffers/Heap Fetches 和 VACUUM 日志。只有业务确实需要返还 OS 且物理低密度造成显著读放大时,才评估 FULL、分区迁移或 pg_repack。
理论题 2
题目:某表 reltuples=400,000,000,base threshold 为 50,scale factor 为 0.2。忽略 PG18 max cap 时,近似触发值是多少?为什么可能不合理?
参考答案:约 50 + 0.2 × 400,000,000 = 80,000,050 个更新/删除。若每秒产生 20,000 个旧版本,要约 4,000 秒才达到阈值,期间还要加 Launcher/Worker 排队和清理时间;对低延迟 OLTP 可能形成巨大读/写/空间放大。应按允许垃圾窗口反推阈值,并验证单次清理吞吐;PG18 可用 max threshold 封顶,但不能替代容量测试。
理论题 3
题目:解释 Slot 的 restart_lsn、xmin 和 catalog_xmin,并给出各自风险。
参考答案:restart_lsn 是 Slot 仍可能需要的最早 WAL,过老导致 pg_wal/归档存储增长;xmin 阻止普通表中更晚删除的旧版本被 VACUUM 移除;catalog_xmin 对系统目录版本起相似作用,逻辑解码尤其相关。三者可独立变化,处置也不同:恢复消费者可推进全部或部分状态;删除 Slot 会改变 CDC 连续性,必须有重建方案。
理论题 4
题目:为什么只监控 XID age 不足以防止环绕事故?
参考答案:Tuple 被多个事务锁定时会使用 MultiXact ID 和成员存储;热点行、外键、FOR SHARE/KEY SHARE 等可快速消耗 MultiXact。它有独立的 relminmxid/datminmxid、age 和触发/硬限制,可能早于普通 XID 进入风险。必须同时监控 mxid_age()、成员存储增长和热点锁工作负载。
理论题 5
题目:为什么有覆盖索引,查询仍可能需要访问 Heap?
参考答案:索引不存储足够的事务可见性信息;只有 VM 显示对应 Heap Page all-visible 时,Index Only Scan 才能跳过 Heap 检查。近期 DML 清除 VM bit,长事务或 Vacuum 落后阻止恢复,导致 Heap Fetches 上升。替代/修复包括提前 Vacuum、缩短事务、降低 churn、调整索引和查询;强制某个 Plan 不能解决可见性成本。
20.2 实验题(3 题)
实验题 1:比较 HOT 与非 HOT 更新
任务:复制实验一,创建两张结构相同的表。A 表只更新未索引列并设置适当 fillfactor;B 表更新索引列。比较 n_tup_hot_upd/n_tup_upd、Heap/Index size、WAL、Buffers 和 Vacuum 时间。
参考答案要点:
- 两表装载相同数据、相同初始索引;清空缓存与否必须一致记录;
- A 的更新列不能被普通逐行索引引用,并给同页预留空间;B 明确修改索引 key;
- 每轮独立提交,记录相同更新行数;
- 预期 A 的 HOT 比例更高、索引增长/WAL 较少,但 fillfactor 让初始 Heap 更大;
- 若 A HOT 比例仍低,检查页内空间、行宽增长、表达式/部分索引依赖和并发更新;
- 不能据单一耗时得出普适结论,必须报告硬件、并发、缓存、P95/P99 和 I/O。
实验题 2:验证 insert-trigger Vacuum 与 Index Only Scan
任务:创建 append-only 表和覆盖索引,批量插入后比较 VACUUM 前后 VM/all-frozen、relallvisible/relpages 和 Heap Fetches;再插入一批新数据观察 VM 被部分清除。
参考答案要点:
- 表不做 UPDATE/DELETE,证明“没有 Dead Tuple”仍需 Vacuum;
ANALYZE后运行覆盖查询记录计划;- 普通或
VACUUM (FREEZE, ANALYZE)后检查pg_class.relallvisible/relallfrozen,可选用pg_visibility; - 预期 VACUUM 后 Index Only Scan 的 Heap Fetches 降低;新插入页面尚未 all-visible,再次上升;
VACUUM FREEZE会增加扫描/WAL,不应仅为追求 0 Heap Fetches 在高频写表反复执行。
实验题 3:评估成本节流、Buffer ring 与 PG18 AIO
任务:在隔离 PG18 环境,对同一可重复数据集分别调整 autovacuum_vacuum_cost_delay/limit 或手工 VACUUM 的 cost、BUFFER_USAGE_LIMIT,并在可选 io_method=worker/io_uring/sync 环境比较维护吞吐和前台 P99。
参考答案要点:
- 每次恢复相同 bloat 起点;记录
shared_buffers、OS Cache 状态、maintenance_io_concurrency、存储类型; - 同时采集 progress blocks/s、delay time、
pg_stat_io、WAL、Checkpoint、设备队列和前台 P50/P95/P99; - 较大 ring/AIO 并发可能加快 Vacuum,也可能挤压热点缓存或存储队列;
- cost 参数不是精确带宽,跨缓存状态不能直接比较;
- 结论应是某硬件/工作负载下的容量边界,不是固定推荐值。
20.3 排障题(2 题)
排障题 1
题目:Autovacuum 每 20 分钟都处理一张表,日志显示成功,但表和索引继续增长,n_dead_tup 也不低。给出排查顺序。
参考答案:
- 确认统计 reset 与实际净数据增长;拆分 Heap/TOAST/每个 Index 大小;
- 看 VACUUM VERBOSE/progress 的 not removable、index cycles、phase、WAL 和 duration;
- 按
backend_xmin/xact_start查长事务、idle、Prepared Transaction、Slot 与 feedback; - 检查垃圾生成速率是否高于清理速率,阈值是否触发太晚;
- 看 HOT 比例和索引数量,确认是否非 HOT 写放大;
- 用
pgstattuple_approx/pgstatindex验证实际 dead/free/density; - 若 horizon 是根因先释放;若清理吞吐不足,再调每表阈值、cost/Worker/内存并做资源准入;
- Heap 已稳定复用则无需缩文件;索引单独异常用 concurrent reindex;必须返还 Heap 空间才评估重写。
排障题 2
题目:告警显示 age(datfrozenxid) 快速上升,pg_wal 也将满;现场有人建议关闭 Autovacuum、删除所有 Slot 并执行 VACUUM FULL。你如何处置?
参考答案:
- 拒绝组合操作:关闭 Autovacuum 会加重环绕;无差别删除 Slot 可能丢 CDC/复制连续性;FULL 需要强锁/额外盘且不是最快 Freeze 路径。
- 先计算 XID/MXID 消耗斜率与剩余时间,列最老 Database/Relation;
- 查询最老 transaction/backend_xmin、Prepared Transaction、Slot
restart_lsn/xmin/catalog_xmin和 feedback; - WAL 满与 XID horizon 分开处置:恢复消费者/归档,只有在获批的数据重建方案下删除特定 Slot;
- 释放最老 horizon 后,让 anti-wrap Worker 完成,或对最老表执行普通 VACUUM;紧急时可跳过非必要 Index Cleanup/Truncation,之后补债;
- 限制非关键写入,保护剩余 XID、I/O 和磁盘;
- 持续验证 age 下降、WAL 可回收、归档/副本追平,并补充根因修复。
20.4 系统设计题(1 题)
题目
设计一个 24×7 多租户 PostgreSQL 平台的 Vacuum 治理方案:有 20 TB Primary、两个物理副本、逻辑 CDC、Go 微服务和小时级分析查询。要求写入 P99 稳定、RPO 小、不能因环绕停写,并支持在线 bloat 修复。
完整参考答案
数据与事务层:按时间/租户访问模式分区,控制单分区大小;Go 服务使用 pgxpool 有界连接和 goroutine,数据库事务不包含外部 RPC,设置基于业务 P99 的 statement/idle/transaction timeout;写入端有 admission control 和幂等重试。
Autovacuum policy:按每表变更速率和垃圾窗口推导 threshold/scale,PG18 用 max threshold 封顶大表;insert/analyze/freeze/TOAST 单独建模。Worker slots、max workers、cost、内存和 AIO 并发由 CPU/I/O/WAL/Replica 容量压测确定。封存分区执行 ANALYZE/FREEZE,数据淘汰用 DROP/DETACH 分区而非巨量 DELETE。
可观测性:采集表/索引/TOAST size slope、dead trigger overdue、Vacuum progress/time、HOT、VM/Heap Fetches、XID/MXID time-to-limit、长事务/Prepared、Slot 三种 horizon、WAL/Checkpoint/归档、Replica lag/conflict、OS I/O 与 pgxpool acquire P99。阈值按服务和表分级,不使用统一 dead_pct。
复制与分析:同步/异步副本角色分离;承载 RPO 的同步候选不跑无界分析。小时级查询放专用延迟副本或分析库,设置查询时限和有界 feedback;监控 backend_xmin。CDC Slot 有 owner、最大离线时间、WAL 配额、证书/心跳告警和全量重建 Runbook。
在线修复:先诊断 Heap/Index/TOAST;索引使用 REINDEX CONCURRENTLY;Heap 优先分区迁移/交换,非分区场景预演 pg_repack。所有重写需额外盘、WAL/归档/副本容量、lock timeout、DDL freeze、CDC/触发器验证、失败清理和审批。FULL 只用于可接受停写的维护窗。
HA 与安全:维护控制面每次执行前确认当前 Primary 和 fencing token;Failover 后重新检查 Autovacuum、Slot、age 和正在执行任务。备份/PITR 覆盖膨胀与重写峰值,并定期恢复校验。环绕告警按 time-to-trigger 分级,紧急预案优先清 horizon 和普通 VACUUM,禁止自动 FULL/删除 Slot/关闭 Autovacuum。
取舍:更频繁 Vacuum 会增加持续 I/O/WAL,但可降低单次峰值与 bloat;feedback 提高长查询成功率但增加主库空间风险;在线 repack 降低停写却增加磁盘、WAL 和控制面复杂度。设计目标是可预测的资源预算和明确的故障成本,而不是把任一指标压到零。
21. 章节检查清单
- 我能够解释 LIVE、Recently Dead、Dead 和 Frozen 的状态转换。
- 我能够解释 Page Pruning/HOT Pruning 为什么不能替代 VACUUM。
- 我能够画出 Lazy VACUUM 的 Heap 扫描、Index Cleanup、Heap Cleanup、VM/FSM 与 Truncation 路径。
- 我能够解释普通 VACUUM 为什么通常不把空间返还操作系统。
- 我能够说明 VACUUM FULL、REINDEX CONCURRENTLY 与 pg_repack 的锁、磁盘、WAL 和失败边界。
- 我能够按表规模、变更速率和垃圾窗口推导 Autovacuum 阈值,而不是套用统一 scale factor。
- 我能够诊断长事务、idle in transaction、Prepared Transaction、Slot 与 hot standby feedback 的 horizon。
- 我能够准确区分 Slot 的 WAL 保留和 Tuple/Catalog 回收影响。
- 我能够同时监控 XID 与 MultiXact,并计算 time-to-trigger/time-to-stop。
- 我能够用
pg_stat_all_tables、pg_stat_progress_vacuum、pg_stat_activity、pg_replication_slots和pgstattuple验证问题。 - 我能够解释 VM 与 Index Only Scan 的 Heap Fetches 关系。
- 我能够分析 Vacuum 对 CPU、内存、shared buffers、OS Cache、AIO、I/O、WAL、Checkpoint 和复制延迟的影响。
- 我能够复现实验中的非 HOT bloat、长快照和 FULL 锁等待。
- 我能够在 Go 中使用 pgxpool、context、有界并发、参数化 SQL、Rows 关闭/错误检查和优雅停机实现健康检查。
- 我能够区分数据库并发、goroutine、连接数、活跃查询、TPS 和排队请求。
- 我能够执行环绕、bloat、Slot 和副本冲突 Runbook,并验证修复结果。
- 我能够评估本章问题对性能、并发、RPO、RTO、备份、PITR 和故障切换的影响。
22. 官方资料来源
以下资料核对日期:2026-06-20。生产实施时仍应按目标小版本重新核对 Release Notes、参数上下限和扩展兼容性。
PostgreSQL 18 官方文档
- Routine Vacuuming:日常 VACUUM、空间复用、Freeze、XID/MultiXact 环绕与 Autovacuum。
- VACUUM SQL Command:FULL、FREEZE、ANALYZE、INDEX_CLEANUP、TRUNCATE、PARALLEL、BUFFER_USAGE_LIMIT 等语法与锁。
- Vacuuming Configuration:Autovacuum Launcher/Worker、threshold/scale/insert、freeze、cost、failsafe 与 PG18 参数。
- Resource Consumption:
maintenance_work_mem、autovacuum_work_mem、vacuum_buffer_usage_limit、PG18 AIO 参数。 - Cumulative Statistics Views:
pg_stat_all_tables、pg_stat_activity、pg_stat_replication、pg_stat_io、pg_stat_wal等。 - Progress Reporting:
pg_stat_progress_vacuum、pg_stat_progress_cluster、pg_stat_progress_create_index。 - Free Space Map、Visibility Map、HOT:Page 辅助结构与 HOT 更新/剪枝。
pg_replication_slots:restart_lsn、xmin、catalog_xmin、wal_status和失效原因。- Hot Standby:恢复冲突、Standby delay 与
hot_standby_feedback。 pgstattuple:精确/近似 Tuple 与 B-tree 物理统计。REINDEX:普通与 CONCURRENTLY 重建的锁、阶段、失败和额外负载。- PostgreSQL 18 Release Notes:AIO、eager freezing、Worker slots、max threshold、维护耗时统计、cost delay timing 等变化。
PostgreSQL 14—17 Release Notes
- PostgreSQL 14 Release Notes:Vacuum failsafe、
INDEX_CLEANUP=AUTO、B-tree/Vacuum 改进。 - PostgreSQL 15 Release Notes:统计系统与 VACUUM/Autovacuum 日志、冻结推进变化。
- PostgreSQL 16 Release Notes:
BUFFER_USAGE_LIMIT/vacuum_buffer_usage_limit与 HOT/BRIN 变化。 - PostgreSQL 17 Release Notes:VACUUM 内存管理、progress 字段重命名与
old_snapshot_threshold移除。
PostgreSQL 官方源码
htup_details.h(REL_18_STABLE):Heap Tuple Header、可见性与 HOT 标志。pruneheap.c(REL_18_STABLE):Page/HOT Pruning、line pointer 重定向与压实。vacuumlazy.c(REL_18_STABLE):Lazy VACUUM 扫描、Dead TID、索引/Heap 清理与截断状态机。
官方项目资料
- pg_repack 官方文档:支持版本、主键要求、约两倍额外空间、锁、触发器日志、限制与失败诊断。
- pgx/v5 官方仓库 与 pgxpool API:Context、Pool、Query/Rows、Close、Stat 与参数化执行。